✨ feat(vscode): 代码阅读的注释挂到它讲的代码块上成「注释」一行,重写注释改用技能一句话加结构化结果

- 普通注释不再单独成卡片:同一层挂到后面最近的块(隔空行也算),后面没有挂到前面那块,整层没代码挂到父块;只有文件开头隔着空行的注释、模块 docstring、//!、@file 单独成「文件开头的说明」
- 各语言(Python、TypeScript、Rust、Shell、Dockerfile)建好树后统一调 attachComments;块的编辑范围连着挂上的注释,差异、跳转、钉住卡片都按它
- 宿主统一给每张卡片(语言卡片、翻译插件的属性表、库规则卡片、一行译文的语句卡片)插「注释」一行,排在名称、说明后面
- TypeScript 的 JSDoc 也能当常量、变量的说明;sqlx、React 不再单独处理前面的注释
- 「重写注释」每块都有:提示只发 /ecc:writing-code-comments 路径:起-止,模型只读、按 --json-schema 交 body,没交结构化正文就失败不写;扩展换掉块前面的注释或在块上面新写一段
This commit is contained in:
cruldra committed 2026-10-08 07:51:21 +08:00
1 parent 335cf9ca9f
commit 582ac4c987
49 files changed
+1730 -757

No files matched your search

+71 -2
View File
@@ -4,9 +4,12 @@ import { describe, expect, it } from 'vitest'
import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView'
import { replaceLines } from './editText'
import { createBashAdapter } from './languages/bash'
import { createDockerfileAdapter } from './languages/dockerfile'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport'
import { commentText, DOCKERFILE_WASM_DIR, previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport'
import { bashZhTranslator } from './translators/bashZh'
import { dockerfileZhTranslator } from './translators/dockerfileZh'
import { pythonZhTranslator } from './translators/pythonZh'
import { typescriptZhTranslator } from './translators/typescriptZh'
@@ -159,7 +162,8 @@ describe('blockView', () => {
' 管理员可查看任意用户,普通用户只能查看自己。',
' """',
])
expect(getUser?.children[0]).toMatchObject({ kind: 'comment', startLine: 38, endLine: 38 })
expect(getUser?.children[0]).toMatchObject({ kind: 'if', startLine: 38, endLine: 40, headerEndLine: 39 })
expect(commentText(getUser?.children[0])).toBe('非管理员只能查看自己')
})
it('类的说明同样算在类块的头部里:JSDoc 连着中间的装饰器,docstring 连着它前面的注释', async () => {
@@ -385,3 +389,68 @@ describe('blockView', () => {
expect(await kind('pass')).toBeUndefined()
})
})
describe('卡片上的「注释」一行', () => {
it('luffy 的 acquisition/repository.py:讲类的注释是类卡片的「注释」一行(排在名称后面),类的编辑范围、源码连着这几行', async () => {
const source = readFixture('acquisitionRepository.py')
const list = await views(py, 'python', pythonZhTranslator, source)
expect(list.some(view => view.kind === 'comment')).toBe(false)
const repo = list.find(view => view.kind === 'class')
expect(repo).toMatchObject({ startLine: 12, headerEndLine: 20, commentRow: 1, commentTarget: { kind: 'replace', startLine: 12, endLine: 15 } })
expect('properties' in repo! && repo.properties.map(row => row.label).slice(0, 3)).toEqual(['名称', '注释', '访问级别'])
expect(commentText(repo)).toBe([
'luffy_agent.user.models.User 在各方法体内按需局部导入 (非模块顶层):',
'user 包 __init__ 会级联加载到 acquisition.service, 而 acquisition 是整个',
'应用最早被 import 的域之一 (见 api_routers.py 的 include 顺序), 顶层导入',
'会与该级联互相等待, 构成循环导入。',
].join('\n'))
expect(repo?.code.split('\n').slice(0, 7)).toEqual([
'# luffy_agent.user.models.User 在各方法体内按需局部导入 (非模块顶层):',
'# user 包 __init__ 会级联加载到 acquisition.service, 而 acquisition 是整个',
'# 应用最早被 import 的域之一 (见 api_routers.py 的 include 顺序), 顶层导入',
'# 会与该级联互相等待, 构成循环导入。',
'',
'',
'class AcquisitionChannelRepository(',
])
expect(replaceLines(source, repo!.startLine, repo!.endLine, repo!.code)).toBe(source)
})
it('说明和注释都有时排成 名称、说明、注释;几段注释是几段 Markdown;一行译文的卡片「注释」一行排在译文下面', async () => {
const source = [
'import { readFile } from \'node:fs/promises\'',
'',
'// 讲 load',
'',
'// 第二段',
'/** 读出配置。 */',
'export async function load(path: string) {',
' // 先检查',
' if (!path) {',
' return null',
' }',
' return readFile(path)',
'}',
].join('\n')
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, commentRow: 2, commentTarget: { kind: 'replace', startLine: 2, endLine: 4 } })
const [check, returned] = load.children
expect(check).toMatchObject({ kind: 'if', startLine: 7, commentRow: 0, rows: [{ label: '注释', value: { markdown: '先检查' } }] })
expect(returned).toMatchObject({ kind: 'return', commentTarget: { kind: 'insert', line: 11 } })
expect(returned).not.toHaveProperty('commentRow')
})
it('嵌着的代码里的块不能写注释:没有 commentTarget', async () => {
const shell = createBashAdapter(WASM_DIR)
const source = 'FROM node:22\nRUN npm ci && npm run build\n'
const { blocks } = await readBlockViews(runtime, createDockerfileAdapter(DOCKERFILE_WASM_DIR), dockerfileZhTranslator, 'dockerfile', source, {
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?.children.length).toBeGreaterThan(0)
expect(run?.children.every(step => step.commentTarget === undefined)).toBe(true)
})
})
+110 -31
View File
@@ -5,7 +5,7 @@ import type { ImportedName } from './symbolLinks'
import { blockAttributes } from './attributes'
import { KIND_ICONS } from './contract'
import { commonIndent, splitLines } from './editText'
import { commentSpan } from './languages/shared'
import { isCommentBlock } from './languages/shared'
import { activeRules, claimBlock, importsIn } from './libraryRules'
import { BlockAnchors } from './qa/anchor'
import { importedNames } from './symbolLinks'
@@ -38,9 +38,13 @@ interface BlockViewBase {
* 同一个 id 只留第一个。项目入口那一块的第一个换成入口(见 entries/entryMarks.ts)。webview 按这个画图标,不自己用种类查。
*/
concepts: BlockConcept[]
/** 能重写的那段注释的起止行,0 起。注释块是整块;函数、类等是它的说明。没有说明就没有,不显示重写按钮。 */
commentStartLine?: number
commentEndLine?: number
/**
* 「注释」一行(讲这块的普通注释,BlockShape.comments)排在第几行,0 起:属性表卡片是 properties 里的下标,
* 一行译文的卡片是 rows 里的下标。没有注释就没有。
*/
commentRow?: number
/** 「重写注释」写到哪(见 CommentTarget);不能写注释的块(嵌着的代码里的、语言没说行注释怎么写又还没有注释的)没有。 */
commentTarget?: CommentTarget
/** 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色(RuleCard.badge)。图标在 concepts 里。预览同类块一样默认收起它的子块。 */
badge?: KindBadge
/** 库规则挂在父卡片子行上的子块才有(见 RuleSlot):展开时画在父卡片属性表第 row 行下面,不排在父卡片的子块里。 */
@@ -55,10 +59,19 @@ export interface BlockSlot {
}
/**
* 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文)。
* 「重写注释」写到哪,行号 0 起:replace 是换掉 startLine..endLine 这几行(块前面那几段普通注释;文件开头的说明卡片是它自己),
* insert 是块还没有注释,新写一段插在 line 这一行上面(块的第一行),缩进同这一行。
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown } | { properties: PropertyRow[] })
export type CommentTarget
= | { kind: 'replace', startLine: number, endLine: number }
| { kind: 'insert', line: number }
/**
* 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文。
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文),
* 几行属性(rows:「注释」一行)。
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] })
interface Line { start: number, end: number }
@@ -211,9 +224,20 @@ function later(a: SpanEnd, b: SpanEnd): SpanEnd {
return b.endIndex > a.endIndex ? b : a
}
/** 各子孙块的头部、节点里最靠后的结尾,和 end 比取靠后的:子孙一般写在块的节点里,Dockerfile 阶段的指令写在 FROM 后面。 */
/** 讲这块的注释(BlockShape.comments)里最靠后的结尾,和 end 比取靠后的:这一层最后一块代码后面的注释挂在它上面。 */
function commentsEnd(block: CodeBlock, end: SpanEnd): SpanEnd {
return (block.comments ?? []).reduce((acc, comment) => later(acc, comment.header), end)
}
/** 块的完整范围从哪起:头部和讲它的注释里最靠前的那个开头(注释写在块上面)。 */
function blockStart(block: CodeBlock): Pick<SourceSpan, 'startIndex' | 'startRow'> {
const first = block.comments?.[0]?.header
return first && first.startIndex < block.header.startIndex ? first : block.header
}
/** 各子孙块的头部、节点、讲它的注释里最靠后的结尾,和 end 比取靠后的:子孙一般写在块的节点里,Dockerfile 阶段的指令写在 FROM 后面。 */
function descendantsEnd(blocks: readonly CodeBlock[], end: SpanEnd): SpanEnd {
return blocks.reduce((acc, child) => descendantsEnd(child.children, later(later(acc, child.header), nodeEnd(child.node))), end)
return blocks.reduce((acc, child) => descendantsEnd(child.children, commentsEnd(child, later(later(acc, child.header), nodeEnd(child.node)))), end)
}
/** index 之前最后一个非空白字符的结尾,不早于头部开头。 */
@@ -224,28 +248,49 @@ function lastCharBefore(source: string, header: SourceSpan, index: number): Span
return { endIndex: end, endRow: header.startRow + splitLines(source.slice(header.startIndex, end)).length - 1 }
}
/** 一块在源码里的完整范围(whole,编辑范围),和不算讲它的注释时这条语句的范围(statement)。 */
interface BlockSpan {
whole: SourceSpan
statement: SourceSpan
}
/**
* 一串兄弟块各自在源码里的完整范围:从头部开头,到头部、所在语句、各子孙块里最靠后的那个字符。
* 一串兄弟块各自在源码里的范围:语句从头部开头,到头部、所在语句、各子孙块里最靠后的那个字符;完整范围再连同讲它的注释。
* 语言插件把 else、catch 这类子句展平成紧跟着的兄弟块,它们的节点都落在打头那块的节点(整条 if / try)里:
* 这条语句按各块的开头切开,每块到下一块开头之前最后一个非空白字符为止,最后一块到语句结尾(Shell 的 fi 归它)。
* 这条语句按各块的开头切开,每块到下一块开头(连同讲它的注释)之前最后一个非空白字符为止,最后一块到语句结尾(Shell 的 fi 归它)。
*/
function blockSpans(list: readonly CodeBlock[], source: string): SourceSpan[] {
function blockSpans(list: readonly CodeBlock[], source: string): BlockSpan[] {
let statement: Node | undefined
return list.map((block, i) => {
if (!statement || block.node.startIndex < statement.startIndex || block.node.endIndex > statement.endIndex)
statement = block.node
const next = list[i + 1]
const nextStart = next && Math.min(next.header.startIndex, next.node.startIndex)
const nextStart = next && Math.min(blockStart(next).startIndex, next.node.startIndex)
const until = nextStart !== undefined && nextStart < statement.endIndex ? lastCharBefore(source, block.header, nextStart) : nodeEnd(statement)
const { endIndex, endRow } = descendantsEnd(block.children, later(block.header, until))
return { startIndex: block.header.startIndex, endIndex, startRow: block.header.startRow, endRow }
const end = descendantsEnd(block.children, later(block.header, until))
const wholeEnd = commentsEnd(block, end)
const start = blockStart(block)
return {
whole: { startIndex: start.startIndex, startRow: start.startRow, endIndex: wholeEnd.endIndex, endRow: wholeEnd.endRow },
statement: { startIndex: block.header.startIndex, startRow: block.header.startRow, endIndex: end.endIndex, endRow: end.endRow },
}
})
}
/**
* 「注释」一行插进属性表:排在第一行(名称这类讲它是谁的一行)后面,有说明(值是 Markdown 的一行)时排在说明后面。
* 返回新的属性表和「注释」一行的下标。
*/
function withCommentRow(rows: readonly PropertyRow[], row: PropertyRow): { rows: PropertyRow[], at: number } {
const doc = rows.findIndex(item => 'markdown' in item.value)
const at = doc >= 0 ? doc + 1 : Math.min(1, rows.length)
return { rows: [...rows.slice(0, at), row, ...rows.slice(at)], at }
}
/**
* 代码块树 → 视图数据:块的完整范围整行源码去共同缩进、按语法树着色,调用翻译插件。
* 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块),
* 并进去的子块不再单独成块。
* 并进去的子块不再单独成块。讲块的注释(BlockShape.comments)排成卡片上的「注释」一行(见 withCommentRow)。
*/
export function toBlockViews(
blocks: CodeBlock[],
@@ -281,8 +326,9 @@ export function toBlockViews(
/**
* 一种语言的块怎么转:用这种语言的源码、翻译插件和库规则(嵌着的代码没有库规则)。
* 转的是一串兄弟块:各块的完整范围(编辑范围)按这一串算(见 blockSpans),id 是 prefix 接上它在这一串里的位置(从 from 数起)。
* writable 为假时(嵌着的代码)块不能写注释:新写的行注释会写进外层语言的那几行里。
*/
function layer(layerBlocks: readonly CodeBlock[], layerDocument: SourceDocument, layerTranslator: Translator | undefined, layerRules: RuleRun): (list: readonly CodeBlock[], prefix: string, scope: string, from?: number) => BlockView[] {
function layer(layerBlocks: readonly CodeBlock[], layerDocument: SourceDocument, layerTranslator: Translator | undefined, layerRules: RuleRun, writable: boolean): (list: readonly CodeBlock[], prefix: string, scope: string, from?: number) => BlockView[] {
const imports = importsIn(layerBlocks)
const active = activeRules(layerRules.rules, imports)
const context: RuleContext = {
@@ -308,7 +354,7 @@ export function toBlockViews(
return []
const steps = part.steps.blocks
const [only] = steps
return layer(steps, part.document, part.translator, NO_RULES)(steps.length === 1 && only ? only.children : steps, `${id}.`, scope, from)
return layer(steps, part.document, part.translator, NO_RULES, false)(steps.length === 1 && only ? only.children : steps, `${id}.`, scope, from)
}
/**
* 这块的概念,从左到右,同一个 id 只留第一个:语言的种类(图标、名字用内置种类和翻译插件起的种类名),
@@ -337,9 +383,42 @@ export function toBlockViews(
}
return concepts
}
function commentLines(block: CodeBlock): { commentStartLine: number, commentEndLine: number } | undefined {
const span = commentSpan(block)
return span && { commentStartLine: span.startLine, commentEndLine: span.endLine }
/** 「注释」一行:讲这块的几段注释(BlockShape.comments)各读成一段 Markdown,空行隔开;名字是翻译插件给种类 comment 起的。没有注释、没有翻译插件时没有。 */
function commentRow(block: CodeBlock): PropertyRow | undefined {
if (!layerTranslator || !block.comments)
return undefined
const parts = block.comments.flatMap((comment) => {
const { text } = layerTranslator.translate(comment, layerDocument)
return 'markdown' in text && text.markdown !== '' ? [text.markdown] : []
})
const label = layerTranslator.attributeNames?.values.kind?.comment ?? 'comment'
return parts.length > 0 ? { label, value: { markdown: parts.join('\n\n') } } : undefined
}
/** 属性表卡片的属性表连同「注释」一行(见 withCommentRow)。 */
function cardRows(block: CodeBlock, rows: PropertyRow[]): Pick<BlockView, 'commentRow'> & { properties: PropertyRow[] } {
const row = commentRow(block)
if (!row)
return { properties: rows }
const { rows: properties, at } = withCommentRow(rows, row)
return { properties, commentRow: at }
}
/** 「重写注释」写到哪(见 CommentTarget):块前面的普通注释换掉,没有就新写在块上面。 */
function commentTarget(block: CodeBlock): Pick<BlockView, 'commentTarget'> {
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 [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 } } : {}
}
/** 一行译文的卡片:译文、实参表、说明,有注释时下面再排「注释」一行。 */
function textCard(block: TextBlock): Pick<BlockView, 'commentRow'> & ReturnType<typeof textView> & { rows?: PropertyRow[] } {
const row = commentRow(block)
return { ...textView(block), ...(row && { rows: [row], commentRow: 0 }) }
}
/** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):编辑范围是 span,头部是块自己那部分,子块照常交给各规则看。scope 见 BlockAnchors.place */
@@ -355,19 +434,19 @@ export function toBlockViews(
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
attributes: blockAttributes(block),
concepts: blockConcepts(block),
...commentLines(block),
...commentTarget(block),
children: [
...list(block.children, `${id}.`, inner),
...embeddedChildren(block, id, inner, block.children.length),
],
...('facts' in block ? { properties: layerTranslator ? layerTranslator.describe(block, layerDocument) : [] } : textView(block)),
...('facts' in block ? cardRows(block, layerTranslator ? layerTranslator.describe(block, layerDocument) : []) : textCard(block)),
}
}
/**
* 块先问启用的库规则,没认领就照它自己的样子。认领了的:规则讲的是整条语句,头部就是整个范围;
* 规则没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序留作它的子块。规则略去的块(讲在了别的卡片里)不出块。
*/
function build(block: CodeBlock, span: SourceSpan, id: string, scope: string): BlockView | undefined {
function build(block: CodeBlock, { whole: span, statement }: BlockSpan, id: string, scope: string): BlockView | undefined {
const claim = active.length > 0 ? claimBlock(block, active, context, layerRules.report) : undefined
if (!claim)
return plain(block, span, id, scope)
@@ -376,25 +455,25 @@ export function toBlockViews(
return undefined
const kept = block.children.filter(child => !replacement.absorbs.includes(child))
if ('as' in replacement) {
const { attributes } = block
return plain({ ...replacement.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, span, id, scope)
const { attributes, comments } = block
return plain({ ...replacement.as, node: block.node, header: statement, children: kept, ...(attributes && { attributes }), ...(comments && { comments }) }, span, id, scope)
}
const { kind, badge, rows, defines, attributes } = replacement
const slots = new Map((replacement.slots ?? []).map(({ block: child, row, item }): [CodeBlock, BlockSlot] => [child, { row, item }]))
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(span))
const { anchor, inner } = anchors.place(scope, kind, defines && source.slice(defines.startIndex, defines.endIndex), rowsText(statement))
return {
id,
anchor,
kind,
...sourceRows(span),
headerEndLine: span.endRow,
headerEndLine: statement.endRow,
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
attributes: { ...blockAttributes(block), ...attributes, kind },
concepts: blockConcepts(block, replacement),
...commentLines(block),
...commentTarget(block),
badge,
children: list(kept, `${id}.`, inner, 0, slots),
properties: rows,
...cardRows(block, rows),
}
}
/** 一串兄弟块转成视图数据;略去的块不占位置,id 按出了块的往下数。slots 是父卡片挂在它属性表子行上的那几块(见 BlockSlot)。 */
@@ -412,7 +491,7 @@ export function toBlockViews(
return list
}
return layer(blocks, document, translator, rules)(blocks, '', '')
return layer(blocks, document, translator, rules, true)(blocks, '', '')
}
/**
+192 -57
View File
@@ -1,6 +1,14 @@
import type { BlockView, CommentTarget } from './blockView'
import type { CommentEdit, CommentRewriteDeps, CommentRewriteJob, WriteResult } from './commentRewrite'
import { describe, expect, it } from 'vitest'
import { COMMENT_SKILL, commentProse, commentRewritePrompt, commentSource, isModuleComment, modelCommentBody, refillComment } from './commentRewrite'
import { commonIndent, replaceLines } from './editText'
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 { createTypeScriptAdapter } from './languages/typescript'
import { WASM_DIR, withBlocks } from './testSupport'
import { pythonZhTranslator } from './translators/pythonZh'
import { typescriptZhTranslator } from './translators/typescriptZh'
/** 和预览写回同一条路:正文套回壳,再按原来的缩进替换这几行。 */
function rewrite(file: string, start: number, end: number, body: string): string {
@@ -103,66 +111,193 @@ describe('重写注释后套回原来的壳', () => {
expect(refillComment('const x = 1', '新')).toBeUndefined()
expect(refillComment('# 旧', ' ')).toBeUndefined()
})
it('给模型的正文不带定界符', () => {
expect(commentProse('"""旧说明"""')).toBe('旧说明')
expect(commentProse('# 旧说明\n# 第二行')).toBe('旧说明\n第二行')
expect(commentProse('/**\n * 旧说明\n */')).toBe('旧说明')
expect(commentProse('// 旧说明')).toBe('旧说明')
})
})
const line = (value: unknown): string => JSON.stringify(value)
/** 模型调 StructuredOutput 交结果:工具输入逐段来,再是整条消息 */
function structuredLines(body: string): string[] {
const json = JSON.stringify({ body })
return [
line({ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'tool_use', name: 'StructuredOutput', input: {} } } }),
line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: json.slice(0, 12) } } }),
line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: json.slice(12) } } }),
line({ type: 'stream_event', event: { type: 'content_block_stop' } }),
line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'StructuredOutput', input: { body } }] } }),
]
}
const resultLine = (fields: Record<string, unknown>): string => line({ type: 'result', subtype: 'success', is_error: false, ...fields })
describe('重写注释的提示', () => {
it('文件顶上的三引号:上下文是去掉这段之后的文件,太长会截掉并说明', () => {
const rest = Array.from({ length: 130 }, (_, index) => `line ${index}`).join('\n')
const prompt = commentRewritePrompt({
comment: '"""旧"""',
source: `"""旧"""\n${rest}`,
startLine: 0,
endLine: 0,
earlierKinds: [],
it('只有技能命令和代码块在哪:路径:起-止,行号 1 起', () => {
expect(commentRewritePrompt('src/acquisition/repository.py', 12, 20)).toBe(`${COMMENT_SKILL} src/acquisition/repository.py:13-21`)
expect(commentRewritePrompt('a.ts', 0, 0)).toBe('/ecc:writing-code-comments a.ts:1-1')
})
})
describe('读模型交回的结构化结果', () => {
it('正文跟着 StructuredOutput 的输入逐段长出来,读文件是正在做的事;结果以 structured_output 为准', () => {
const parse = createCommentParser()
const events = [
line({ type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/w/src/a.py' } }] } }),
...structuredLines('把 `a` 和 `b` 相加。'),
resultLine({ result: '{"body":"把 `a` 和 `b` 相加。"}', structured_output: { body: '把 `a` 和 `b` 相加。' } }),
].flatMap(parse)
expect(events[0]).toEqual({ kind: 'activity', label: '读取 a.py' })
expect(events.filter(event => event.kind === 'text').map(event => 'text' in event && event.text)).toEqual(['把 `', '把 `a` 和 `b` 相加。'])
expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。' })
})
it('模型只回了一段话、structured_output 是空的:结果里没有正文;Claude Code 报错时是失败', () => {
expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。' }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' } }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(line({ type: 'result', is_error: true, result: '额度用完了' }))).toEqual([{ kind: 'error', message: '额度用完了' }])
})
})
/** 内存里的一份文件:按行读,写回时同预览一样换掉几行(补回缩进)或在某行上面插几行。 */
function memoryFile(text: string): { deps: (ask: CommentRewriteDeps['ask']) => CommentRewriteDeps, text: () => string, writes: CommentEdit[] } {
let current = text
const writes: CommentEdit[] = []
const lines = (): string[] => splitLines(current)
return {
text: () => current,
writes,
deps: ask => ({
lineAt: index => lines()[index],
lineCount: () => lines().length,
ask,
failure: err => `没答成:${err instanceof Error ? err.message : String(err)}`,
write: async (edit): Promise<WriteResult> => {
writes.push(edit)
if (edit.kind === 'replace') {
current = replaceLines(current, edit.startLine, edit.endLine, edit.text)
}
else {
const all = lines()
current = [...all.slice(0, edit.line), ...edit.text.split('\n'), ...all.slice(edit.line)].join('\n')
}
return 'written'
},
signal: new AbortController().signal,
}),
}
}
/** 用 Python 语言插件、中文直译把源码读成视图数据,按块的第一行(1 起,含讲它的注释)找到那块。 */
async function pythonBlock(source: string, line: number): Promise<BlockView> {
const py = createPythonAdapter(WASM_DIR)
const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator))
const all = (list: BlockView[]): BlockView[] => list.flatMap(view => [view, ...all(view.children)])
const found = all(views).find(view => view.startLine + 1 === line)
if (!found)
throw new Error(`第 ${line} 行没有块`)
return found
}
function job(block: BlockView, path = 'src/a.py', lineComment = '#'): CommentRewriteJob {
if (!block.commentTarget)
throw new Error('这块不能写注释')
return { target: block.commentTarget, startLine: block.startLine, endLine: block.endLine, path, lineComment }
}
const SOURCE = [
'import os',
'',
'# 旧的说明',
'',
'# 第二段',
'class Repo:',
' def find(self):',
' return os.sep',
'',
].join('\n')
describe('重写一块代码的注释', () => {
it('块前面的注释(几段隔着空行的也算)换成模型交回的正文,套回 # 和缩进;提示只有技能命令和块的范围', async () => {
const repo = await pythonBlock(SOURCE, 3)
expect(repo.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 4 })
const file = memoryFile(SOURCE)
const prompts: string[] = []
const outcome = await rewriteBlockComment(job(repo), file.deps(async (prompt) => {
prompts.push(prompt)
return '仓库:按名字找文件。'
}))
expect(outcome).toEqual({ status: 'done' })
expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:3-8'])
expect(file.text()).toBe(SOURCE.replace('# 旧的说明\n\n# 第二段', '# 仓库:按名字找文件。'))
})
it('还没有注释的块:用行注释记号新写一段,插在块上面,缩进同块的第一行;说明(docstring)不动', 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 })
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')
})
it('文件开头的说明卡片换的是它自己:范围就是这段注释', async () => {
const source = '"""旧的模块说明。"""\n\nimport os\n'
const header = await pythonBlock(source, 1)
expect(header).toMatchObject({ kind: 'comment', commentTarget: { kind: 'replace', startLine: 0, endLine: 0 } })
const file = memoryFile(source)
const prompts: string[] = []
await rewriteBlockComment(job(header), file.deps(async (prompt) => {
prompts.push(prompt)
return '新的模块说明。'
}))
expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:1-1'])
expect(file.text()).toBe('"""新的模块说明。"""\n\nimport os\n')
})
it('没交回结构化的正文、模型出错:失败,文件不动', async () => {
const repo = await pythonBlock(SOURCE, 3)
const silent = memoryFile(SOURCE)
expect(await rewriteBlockComment(job(repo), silent.deps(async () => undefined))).toEqual({ status: 'failed', error: '模型没有按要求交回注释正文,没有改文件。' })
expect(silent.writes).toEqual([])
const broken = memoryFile(SOURCE)
expect(await rewriteBlockComment(job(repo), broken.deps(async () => {
throw new Error('额度用完了')
}))).toEqual({ status: 'failed', error: '没答成:额度用完了' })
expect(broken.writes).toEqual([])
})
it('重写期间这块代码变了:失败,不写;被叫停:stopped,不写', async () => {
const repo = await pythonBlock(SOURCE, 3)
const file = memoryFile(SOURCE)
let edited = SOURCE
const deps = file.deps(async () => {
edited = SOURCE.replace('return os.sep', 'return "/"')
return '新说明'
})
expect(prompt?.startsWith(`${COMMENT_SKILL}\n`)).toBe(true)
expect(prompt).toContain('只按这个技能重写这一段注释')
expect(prompt).toContain('这段注释现在的正文:\n旧')
expect(prompt).not.toContain('"""')
expect(prompt).toContain('截断了')
expect(prompt).toContain('line 0')
expect(prompt).not.toContain('line 129')
})
it('普通注释只带后面紧挨着的那一块,不带整份文件', () => {
const prompt = commentRewritePrompt({
comment: '# 旧',
source: '整份文件不该出现',
startLine: 4,
endLine: 4,
earlierKinds: ['function'],
following: 'def stay():\n return 1',
})
expect(prompt).toContain('def stay():')
expect(prompt).not.toContain('整份文件不该出现')
expect(isModuleComment('# 旧', [])).toBe(false)
expect(isModuleComment('"""旧"""', [])).toBe(true)
expect(isModuleComment('"""旧"""', ['function'])).toBe(false)
})
it('函数上的说明用这块代码当上下文,不当成文件顶上的说明', () => {
const prompt = commentRewritePrompt({
comment: '"""旧说明"""',
source: 'def run():\n """旧说明"""\n return 1\n\ndef other():\n return 2',
startLine: 1,
endLine: 1,
earlierKinds: [],
attached: 'def run():\n return 1',
})
expect(prompt).toContain('def run():')
expect(prompt).toContain('return 1')
expect(prompt).not.toContain('def other()')
const changing: CommentRewriteDeps = { ...deps, lineAt: index => splitLines(edited)[index] }
expect(await rewriteBlockComment(job(repo), changing)).toEqual({ status: 'failed', error: '这块代码在重写期间变了,没有写回。' })
expect(file.writes).toEqual([])
const stop = new AbortController()
const stopped = memoryFile(SOURCE)
const result = await rewriteBlockComment(job(repo), { ...stopped.deps(async () => {
stop.abort()
return '新说明'
}), signal: stop.signal })
expect(result).toEqual({ status: 'stopped' })
expect(stopped.writes).toEqual([])
})
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: '// 启动时读一次' })
})
})
it('模型套了代码围栏时只留里面的正文', () => {
expect(modelCommentBody('```\n新说明\n```')).toBe('新说明')
expect(modelCommentBody(' ')).toBeUndefined()
describe('按记号找块', () => {
it('先序找,找不到是 undefined', () => {
const tree = [{ anchor: 'a', children: [{ anchor: 'b', children: [] }] }, { anchor: 'c', children: [] }]
expect(findBlock(tree, 'b')?.anchor).toBe('b')
expect(findBlock(tree, 'x')).toBeUndefined()
})
})
+209 -108
View File
@@ -1,16 +1,27 @@
/**
* 按注释规范重写一段注释:模型只给新正文,这里套回原来的定界符和缩进。
* 「重写注释」:用写注释的技能给一块代码重写(没有就新写)讲它的普通注释。模型只读文件、按 JSON Schema 交回注释正文,
* 这里套回原来的定界符和缩进(没有注释时用语言的行注释记号),由扩展写回文件。
* 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。
*/
import { commonIndent, splitLines } from './editText'
/** 贴给模型的代码最多这么多行,再长就截断并在提示里说明。 */
export const COMMENT_CONTEXT_LINES = 120
import type { CommentTarget } from './blockView'
import { describeToolUse } from '../cc/claudeProgress'
import { commonIndent } from './editText'
/** 跟在对话框里输入这个命令一样,由 Claude Code 自己加载写注释的技能。 */
export const COMMENT_SKILL = '/ecc: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`。 */
@@ -49,18 +60,6 @@ function docShell(comment: string): DocShell | 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')
@@ -101,11 +100,10 @@ function refillDoc(comment: string, body: string): string | undefined {
interface LineShell {
token: string
spaced: boolean
prose: string
}
/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格、空格后面的字。 */
function lineParts(line: string): { token: string, spaced: boolean, text: string } | undefined {
/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格。 */
function lineParts(line: string): LineShell | undefined {
let end = 0
if (line.startsWith('//')) {
end = 2
@@ -118,8 +116,7 @@ function lineParts(line: string): { token: string, spaced: boolean, text: string
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) }
return { token: line.slice(0, end), spaced: gap === ' ' || gap === '\t' }
}
function lineShell(comment: string): LineShell | undefined {
@@ -133,7 +130,7 @@ function lineShell(comment: string): LineShell | undefined {
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') }
return { token, spaced: rows.some(item => item.spaced) }
}
function refillLine(comment: string, body: string): string | undefined {
@@ -214,14 +211,6 @@ function refillBlock(comment: string, body: string): string | undefined {
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,调用方不写文件。
@@ -233,92 +222,204 @@ export function refillComment(comment: string, body: string): string | undefined
return refillDoc(comment, text) ?? refillLine(comment, text) ?? refillBlock(comment, text)
}
/** 发给模型的整句话:技能命令后面跟代码块在哪,`路径:起-止`(路径相对 claude 的工作目录,行号 1 起,连同讲它的注释),别的不说。startLine、endLine 0 起。 */
export function commentRewritePrompt(path: string, startLine: number, endLine: number): string {
return `${COMMENT_SKILL} ${path}:${startLine + 1}-${endLine + 1}`
}
/**
* 文件顶上的模块说明:前面没有别的代码(可以有别的注释),而且是三引号,或没挂到声明上的 `/**`。
* `#`、`//`、普通块注释不算,它们讲的是后面那一块。
* 从 claude 的 stream-json 输出里读出来的:text 是注释正文到现在为止写出来的(结构化结果逐字长出来),activity 是正在做的事(「读取 routes.py」),
* result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有),error 是 Claude Code 自己报的失败。
*/
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 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)
}
export interface CommentPlace<T extends { anchor: string, children: readonly T[] }> {
block: T
siblings: readonly T[]
index: number
function str(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** 按记号在块树里找到这块,以及它和哪些块排在同一层。 */
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 }
/** 写到一半的结构化结果(`{"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 }
/**
* 新正文落到文件里的样子:换掉原来那段注释时套回它的定界符(几段注释中间隔着空行时照第一段的写法写成一段);
* 块还没有注释时用语言的行注释记号新写一段,缩进同块的第一行。认不出定界符、没有行注释记号、正文是空的时没有。
*/
export function commentEdit(target: CommentTarget, lineAt: (line: number) => string, body: string, lineComment: string | undefined): 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)
return undefined
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') }
}
/** 写回文件的结果:written 写进去了,unchanged 和文件里一样不用写,missing 行号已经不在,rejected 编辑器没接受。 */
export type WriteResult = 'written' | 'unchanged' | 'missing' | 'rejected'
/** 一次重写的结果:done 已经写进文件,failed 没写并带给人看的原因,stopped 是被叫停、没写。 */
export type RewriteOutcome
= | { status: 'done' }
| { status: 'stopped' }
| { status: 'failed', error: string }
/** 给哪块代码重写注释:「重写注释」写到哪,块的起止行(0 起,连同讲它的注释),文件相对 claude 工作目录的路径(`/` 分隔),语言的行注释记号。 */
export interface CommentRewriteJob {
target: CommentTarget
startLine: number
endLine: number
path: string
lineComment?: string
}
/** 重写时要用的:读文件现在的行,问模型(拿到结构化结果里的正文,没有正文时 undefined;失败时抛错),出错时给人看的话,写回文件。 */
export interface CommentRewriteDeps {
lineAt: (line: number) => string
lineCount: () => number
ask: (prompt: string) => Promise<string | undefined>
failure: (err: unknown) => string
write: (edit: CommentEdit) => Promise<WriteResult>
signal: AbortSignal
}
/**
* 重写一块代码的注释:问模型(提示只有技能命令和代码块在哪,见 commentRewritePrompt),拿结构化结果里的正文套回去写进文件。
* 没交回结构化的正文、这块代码在重写期间变了、写不回去都算失败,文件不动。
*/
export async function rewriteBlockComment(job: CommentRewriteJob, deps: CommentRewriteDeps): Promise<RewriteOutcome> {
const failed = (error: string): RewriteOutcome => ({ status: 'failed', error })
const rangeText = (): string | undefined => job.endLine < deps.lineCount()
? Array.from({ length: job.endLine - job.startLine + 1 }, (_, i) => deps.lineAt(job.startLine + i)).join('\n')
: undefined
const snapshot = job.startLine >= 0 && job.endLine >= job.startLine ? rangeText() : undefined
if (snapshot === undefined)
return failed('这块代码的位置已经变了,没有写回。')
let body: string | undefined
try {
body = await deps.ask(commentRewritePrompt(job.path, job.startLine, job.endLine))
}
catch (err) {
return deps.signal.aborted ? { status: 'stopped' } : failed(deps.failure(err))
}
if (deps.signal.aborted)
return { status: 'stopped' }
if (body === undefined)
return failed('模型没有按要求交回注释正文,没有改文件。')
if (rangeText() !== snapshot)
return failed('这块代码在重写期间变了,没有写回。')
const edit = commentEdit(job.target, deps.lineAt, body, job.lineComment)
if (!edit)
return failed('这段注释的定界符认不出来,没有改文件。')
const wrote = await deps.write(edit)
if (wrote === 'written' || wrote === 'unchanged')
return { status: 'done' }
return failed(wrote === 'rejected' ? '编辑器没接受这次修改,没有写回。' : '这块代码在重写期间变了,没有写回。')
}
/** 按记号在块树里找到这块。 */
export function findBlock<T extends { anchor: string, children: readonly T[] }>(blocks: readonly T[], anchor: string): T | undefined {
for (const block of blocks) {
const inner = findPlaced(block.children, anchor)
if (block.anchor === anchor)
return block
const inner = findBlock(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 {
/** 去掉共同缩进的注释原文 */
comment: string
/** 整份文件,用来在是模块说明时拿掉这段注释 */
source: string
startLine: number
endLine: number
earlierKinds: readonly string[]
/** 普通注释后面紧挨着的那一块的原文;没有就不填 */
following?: string
/** 说明挂在这块代码上(函数、类的 docstring)。有它就不当成文件顶上的说明,上下文用这块。 */
attached?: string
}
/** 发给模型的整段话。开头是技能命令,Claude Code 自己加载技能。注释认不出就不组。 */
export function commentRewritePrompt(input: CommentRewriteInput): string | undefined {
const body = commentProse(input.comment)
if (body === undefined)
return undefined
const fileComment = input.attached === undefined && isModuleComment(input.comment, input.earlierKinds)
const context = input.attached !== undefined
? clip(input.attached, '这块代码')
: fileComment
? clip(splitLines(input.source).filter((_, index) => index < input.startLine || index > input.endLine).join('\n').trim(), '文件其余部分')
: input.following
? clip(input.following, '这块代码')
: { text: '(后面没有紧挨着的代码)', note: '' }
return [
COMMENT_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
}
+28 -7
View File
@@ -9,6 +9,7 @@ export type { Node }
* directive 往后给构建文件(Dockerfile):directive 文件开头给构建工具看的解析指令(`# syntax=…`),stage 一个构建阶段(FROM 和它后面的指令),
* run 构建时执行的命令,copy 往镜像里复制文件,setting 一条设置(工作目录、运行身份、端口……),label 镜像的标签,
* health 健康检查,start 容器启动时执行的命令。
* comment 是讲整个文件的注释(文件开头的说明);别的注释不单独成块,挂在它讲的块上(见 BlockShape.comments)。
*/
export type BlockKind
= | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable'
@@ -489,11 +490,17 @@ export interface BlockShape {
/** 这一块对应的语法树节点(整条语句)。 */
node: Node
/**
* 块自己那一段(预览讲的就是它):复合语句只到主体开始之前,其余是整条语句。编辑时宿主给的是整块:头部连同各子块、结尾。
* 块自己那一段(预览讲的就是它):复合语句只到主体开始之前,其余是整条语句。编辑时宿主给的是整块:头部连同各子块、讲它的注释、结尾。
* 块自己的说明(facts.doc、TextBlock.doc)也算在里面:JSDoc 往上延到注释开头,docstring、属性 docstring 往下延到字符串结尾。
*/
header: SourceSpan
children: CodeBlock[]
/**
* 讲这块的普通注释(不是说明),照源码顺序:同一层里它前面的注释(中间隔空行也算),这一层最后一块代码后面的注释,
* 子块一块代码都没有时子块里的注释。由 attachComments(languages/shared.ts)挂上;预览排成卡片上的「注释」一行,
* 块的编辑范围连同这几段注释。只有文件开头的说明单独成块(kind 'comment')。
*/
comments?: CommentBlock[]
/**
* 语言自己报的筛选属性,和宿主推出的通用属性(见 COMMON_ATTRIBUTES)合在一起:通用词表以外的属性(Rust 的 unsafe: 'true'),
* 或 facts 表达不了的通用属性值(Kotlin 的 access: 'internal',盖过从 facts 推出的)。kind 总是块的种类,这里填了不算。
@@ -562,6 +569,12 @@ export interface TextBlock extends BlockShape {
export type CodeBlock = TextBlock | FactBlock
/**
* 一段注释:相邻行同一写法的行注释合成的一段(node 是第一条,头部盖住全部),一条块注释,或 Python 当注释用的字符串。
* 文件开头的说明是单独的一块;别的挂在它讲的块上(BlockShape.comments)。
*/
export interface CommentBlock extends TextBlock { kind: 'comment' }
/** 着色用的词性,源码着色和译文着色共用。 */
export type TokenRole
= | 'keyword' // 流程关键字:import from export if else for return try catch await …
@@ -707,6 +720,8 @@ export interface LanguageAdapter {
toBlocks: (root: Node, source: string, languageId: string) => CodeBlock[]
/** 给语法树叶子节点定词性:源码着色用它,译文里照抄的词也按它着色、按它认出能查定义的名字。 */
tokenRole: (leaf: Node) => TokenRole
/** 行注释的记号(`#`、`//`):「重写注释」给还没有注释的块新写一段时用它;不填就不能新写。 */
lineComment?: string
/** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */
iteration?: (node: Node) => IterationFacts | undefined
/** node 是一次调用(含新建对象)时给出它的结构,别的节点没有;没给的语言不展开调用,照常一行译文。 */
@@ -792,7 +807,10 @@ export interface Translator {
languageIds: string[]
/** 译文用的语言(BCP 47 的语言部分,例如 'zh'):库规则按它挑自己的词表。 */
displayLanguage: string
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文,跨几行写的调用连着摊开实参的表。 */
/**
* 只翻 header 那一段;注释块(文件开头的说明、挂在块上的注释 BlockShape.comments)给去掉注释符号的 Markdown 原文,
* 跨几行写的调用连着摊开实参的表。
*/
translate: (block: TextBlock, document: SourceDocument) => TextTranslation
/** 函数、类、枚举、导入、常量、变量块 → 属性表。 */
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
@@ -929,12 +947,15 @@ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconNam
export type RuleBlock = RuleCard | RuleFacts | RuleOmit
/**
* 这块讲在了别的卡片里,不再单独成块:React 文件开头的 "use client" 讲成了组件卡片上的「客户端组件」标签,
* 组件上面的 JSDoc 讲成了卡片的「说明」。预览不画它;它不能有子块(有子块时宿主当规则没认)。
* 这块讲在了别的卡片里,不再单独成块:React 文件开头的 "use client" 讲成了组件卡片上的「客户端组件」标签。
* 预览不画它;它不能有子块,也不能挂着注释(BlockShape.comments,注释没地方放),有时宿主当规则没认。
*/
export interface RuleOmit { omit: true }
/** 规则自己的卡片:一张属性表。种类(badge)决定筛选和边框色;卡片左边的图标是语言的概念再加这个种类、以及 concepts。用词归规则。 */
/**
* 规则自己的卡片:一张属性表。种类(badge)决定筛选和边框色;卡片左边的图标是语言的概念再加这个种类、以及 concepts。用词归规则。
* 原块的「注释」一行(BlockShape.comments)由宿主插进 rows,规则不用管。
*/
export interface RuleCard {
/** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */
kind: string
@@ -954,8 +975,8 @@ export interface RuleCard {
*/
attributes?: BlockAttributes
/**
* 并进这一块的子块(原块 children 里的对象本身):不再单独成块,内容由 rows 讲。
* 没并进来的子块(方法、别的成员、规则不讲的注释)不管写在哪,都照源码顺序照常是这一块的子块,也照常交给各规则看。
* 并进这一块的子块(原块 children 里的对象本身):不再单独成块,内容由 rows 讲(讲子块的注释在子块的 comments 里,跟着并进来)。
* 没并进来的子块(方法、别的成员)不管写在哪,都照源码顺序照常是这一块的子块,也照常交给各规则看。
* 这一块的编辑范围同别的块,是原块整条语句(类就是整个类),盖住留下的子块:它编辑时预览不画这些子块,同一行不会同时在两处能改。
*/
absorbs: CodeBlock[]
@@ -232,3 +232,17 @@ describe('diffBlockTrees', () => {
expect(diff.removed[0].after).toBeUndefined()
})
})
describe('改了挂在块上的注释', () => {
it('改的是讲这块的注释:这块算修改、自己改了,「注释」一行逐词标;一行译文的卡片标在它下面那几行(rows)上', async () => {
const before = await python('import os\n\n# 讲 load\ndef load(path):\n # 先检查\n return path\n')
const after = await python('import os\n\n# 讲 load,改过\ndef load(path):\n # 先检查一下\n return path\n')
const diff = diffBlockTrees(before, after)
const [, load] = after.blocks
const [returned] = load.children
expect(diff.stats).toEqual({ add: 0, mod: 1, del: 0 })
expect(diff.blocks[load.anchor]).toMatchObject({ change: 'mod', own: true, marks: { rows: { changed: { 1: { value: { markdown: expect.any(Array) } } } } } })
expect(diff.blocks[returned.anchor]).toMatchObject({ change: 'mod', own: true, marks: { rows: { changed: { 0: { value: { markdown: expect.any(Array) } } } } } })
expect(diff.blocks[returned.anchor].marks).not.toHaveProperty('translation')
})
})
+4 -2
View File
@@ -54,7 +54,7 @@ export interface RowDiff {
items?: ItemsDiff
}
/** 一块自己的内容(不算子块)改了哪些:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 */
/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows,「注释」一行)逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 */
export interface BlockMarks {
rows?: RowsDiff
translation?: TextDiff
@@ -114,6 +114,7 @@ export function removedView(view: BlockView): BlockView {
translation: textWithoutSource(view.translation),
...(view.table && { table: tableWithoutSource(view.table) }),
...(view.doc && { doc: view.doc }),
...(view.rows && { rows: view.rows.map(rowWithoutSource) }),
}
}
@@ -315,6 +316,7 @@ export function blockMarks(before: BlockView, after: BlockView): BlockMarks | un
const translation = textDiff(before.translation, after.translation)
const table = itemsDiff(before.table?.items, after.table?.items)
const doc = textDiff(before.doc ?? { markdown: '' }, after.doc ?? { markdown: '' })
const marks: BlockMarks = { ...(translation && { translation }), ...(table && { table }), ...(doc && { doc }) }
const rows = rowsDiff(before.rows ?? [], after.rows ?? [])
const marks: BlockMarks = { ...(translation && { translation }), ...(table && { table }), ...(doc && { doc }), ...(rows && { rows }) }
return Object.keys(marks).length > 0 ? marks : undefined
}
+2 -2
View File
@@ -119,7 +119,7 @@ export interface StickyScrollSettings {
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
* rewriteProgress:anchor 这段注释正在按规范重写。question 是发给模型的要求,text 是到现在为止的全文,activity 是正在读什么
* rewriteProgress:anchor 那块的注释正在按规范重写。question 是发给模型的那一句(技能命令和块在哪),text 是注释正文到现在为止写出来的,activity 是正在读什么
* rewriteEnd:这段重写结束了。done 是正文已经写进文件;failed 带给人看的原因,文件没改;stopped 是被叫停,文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
* displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理
@@ -167,7 +167,7 @@ export type ExtensionToWebview
* setOnlyChanges:打开 / 关掉「只看改动」,扩展端记下后发 filter 给所有预览
* ask:问 anchor 那块代码;askId 由 webview 起,回答的消息带回去;扩展端接着这块最近一次问答的会话问,用眼下选的 profile;
* images 是贴进输入框的图片(没贴是空的),随问题发给 Claude Code,答成了跟这条一起存
* rewriteComment:按注释规范重写 anchor 这段注释。过程用 rewriteProgress / rewriteEnd 发回页面,不写入问答
* rewriteComment:按注释规范重写 anchor 那块的注释(还没有就新写一段)。过程用 rewriteProgress / rewriteEnd 发回页面,不写入问答
* stopRewrite:叫停 anchor 这段正在进行的重写,不写文件
* stopAsk:叫停 askId 那个问题,这次不存
* deleteQa:删掉 anchor 那块下面的一次问答(已经理解了)
+74 -109
View File
@@ -1,5 +1,6 @@
import type { CustomTextEditorProvider, Disposable, DocumentSymbol, Event, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { CommentEdit, RewriteOutcome, WriteResult } from '../commentRewrite'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange, WorkspaceIndex } from '../contract'
import type { DiffBase } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
@@ -16,18 +17,18 @@ import type { FoundLocation } from './definitions'
import type { DefinitionPeek, ExtensionToWebview, Occurrence, Reveal, WebviewToExtension } from './messages'
import type { TargetSpot } from './openTarget'
import { realpath } from 'node:fs/promises'
import { dirname } from 'node:path'
import { commands, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { dirname, relative, sep } from 'node:path'
import { commands, EndOfLine, Position, Range, Uri, window, workspace, WorkspaceEdit } from 'vscode'
import { logger } from '../../logging/logger'
import { webviewHtml, webviewUiRoot } from '../../webview/html'
import { DocumentCallees } from '../calleeKinds'
import { commentRewritePrompt, commentSource, findPlaced, modelCommentBody, refillComment } from '../commentRewrite'
import { findBlock, rewriteBlockComment } from '../commentRewrite'
import { PreviewDiff } from '../diff/previewDiff'
import { blockReplacement } from '../editText'
import { locate } from '../folderStore'
import { definitionFor, symbolAt, symbolOccurrences } from '../localSymbols'
import { DocumentNames, resolveNames } from '../nameResolution'
import { askClaude, askFailure } from '../qa/claudeAsk'
import { askCommentBody, askFailure } from '../qa/claudeAsk'
import { PATH_ASK_ANCHOR } from '../qa/pathQa'
import { askPrompt, followUpPrompt } from '../qa/prompt'
import { DocumentLookups } from '../ruleLookups'
@@ -164,12 +165,6 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
}
}
/** 一段注释重写的结果:`done` 已经写进文件,`failed` 没写并带给人看的原因,`stopped` 是被叫停、没写。 */
type RewriteOutcome
= | { status: 'done' }
| { status: 'stopped' }
| { status: 'failed', error: string }
class Preview {
/** webview 发过 ready 之后才发消息;标签页切走时页面被销毁,切回来重新加载、再发 ready,这期间也不发 */
private ready = false
@@ -962,113 +957,83 @@ class Preview {
this.failRewrite(message)
return { status: 'failed', error: message }
}
const found = this.sent && findPlaced(this.sent.blocks, anchor)
if (!found)
return failed('这块注释刚改过,找不到了。等预览刷新后再重写。')
const { block, siblings, index } = found
const startLine = block.commentStartLine
const endLine = block.commentEndLine
if (startLine === undefined || endLine === undefined)
return failed('这块没有能重写的注释。')
const block = this.sent && findBlock(this.sent.blocks, anchor)
if (!block)
return failed('这块代码刚改过,找不到了。等预览刷新后再重写。')
const target = block.commentTarget
if (!target)
return failed('这块不能写注释。')
const { document } = this
if (startLine < 0 || endLine < startLine || endLine >= document.lineCount)
return failed('这块注释的位置已经变了,没有写回。')
const linesOf = (from: number, to: number): string => Array.from({ length: to - from + 1 }, (_, i) => document.lineAt(from + i).text).join('\n')
const raw = linesOf(startLine, endLine).split('\n')
const snapshot = raw.join('\n')
const comment = commentSource(raw)
const source = document.getText()
const attached = startLine !== block.startLine || endLine !== block.endLine
? [
...(startLine > block.startLine ? [linesOf(block.startLine, startLine - 1)] : []),
...(endLine < block.endLine ? [linesOf(endLine + 1, block.endLine)] : []),
].filter(part => part !== '').join('\n')
: undefined
const next = siblings[index + 1]
const following = attached === undefined && next && next.startLine >= 0 && next.endLine >= next.startLine && next.endLine < document.lineCount
? linesOf(next.startLine, next.endLine)
: undefined
const prompt = commentRewritePrompt({
comment,
source,
startLine,
endLine,
earlierKinds: siblings.slice(0, index).map(item => item.kind),
...(following !== undefined && { following }),
...(attached !== undefined && attached !== '' && { attached }),
})
if (!prompt)
return failed('这段注释的定界符认不出来,没有改文件。')
if (document.isDirty)
return failed('文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。')
const at = locate(document.uri)
let text = ''
let activity: string | undefined
const publish = (): void => {
this.post({
type: 'rewriteProgress',
anchor,
question: prompt,
text,
...(activity !== undefined && { activity }),
})
}
publish()
const cwd = at?.folder.uri.fsPath ?? dirname(document.uri.fsPath)
const profile = await this.owner.qa.profiles.current()
if (signal.aborted)
return { status: 'stopped' }
let answerText: string
try {
const answer = await askClaude({
prompt,
images: [],
...(profile && { profilePath: profile.path }),
cwd: at?.folder.uri.fsPath ?? dirname(document.uri.fsPath),
signal,
onEvent: (event) => {
if (event.kind === 'text') {
text = event.text
activity = undefined
}
else if (event.kind === 'activity') {
activity = event.label
}
else {
return
}
publish()
},
})
answerText = answer.text
let question = ''
let text = ''
let activity: string | undefined
const publish = (): void => {
this.post({ type: 'rewriteProgress', anchor, question, text, ...(activity !== undefined && { activity }) })
}
catch (err) {
if (signal.aborted)
return { status: 'stopped' }
return failed(askFailure(err))
}
if (signal.aborted)
return { status: 'stopped' }
const body = modelCommentBody(answerText)
if (!body)
return failed('模型没有给出注释正文,没有改文件。')
if (endLine >= document.lineCount)
return failed('这段注释在重写期间变了,没有写回。')
const current = linesOf(startLine, endLine)
if (current !== snapshot)
return failed('这段注释在重写期间变了,没有写回。')
const filled = refillComment(comment, body)
if (!filled)
return failed('这段注释的定界符认不出来,没有改文件。')
if (signal.aborted)
return { status: 'stopped' }
const wrote = await this.applyEdit(startLine, endLine, filled)
if (wrote === 'written' || wrote === 'unchanged')
return { status: 'done' }
if (wrote === 'rejected')
return { status: 'failed', error: `没能把修改写回 ${workspace.asRelativePath(document.uri)}` }
return failed('这段注释在重写期间变了,没有写回。')
const outcome = await rewriteBlockComment({
target,
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,
ask: (prompt) => {
question = prompt
publish()
return askCommentBody({
prompt,
...(profile && { profilePath: profile.path }),
cwd,
signal,
onEvent: (event) => {
if (event.kind === 'text') {
text = event.text
activity = undefined
}
else {
activity = event.label
}
publish()
},
})
},
failure: askFailure,
write: edit => this.applyCommentEdit(edit),
signal,
})
if (outcome.status === 'failed')
this.failRewrite(outcome.error)
return outcome
}
/** 把这几行换成 [text]。`written` 写进去了,`unchanged` 跟文件里一样不用写,`missing` 行号已经不在,`rejected` 编辑器没接受。 */
private async applyEdit(startLine: number, endLine: number, text: string): Promise<'written' | 'unchanged' | 'missing' | 'rejected'> {
/** 写回重写好的注释:换掉原来那几行(同块编辑),或在块上面插进新写的几行。 */
private async applyCommentEdit(edit: CommentEdit): Promise<WriteResult> {
if (edit.kind === 'replace')
return this.applyEdit(edit.startLine, edit.endLine, edit.text)
const { document } = this
if (edit.line >= document.lineCount)
return 'missing'
const eol = document.eol === EndOfLine.CRLF ? '\r\n' : '\n'
const change = new WorkspaceEdit()
change.insert(document.uri, new Position(edit.line, 0), edit.text.split('\n').join(eol) + eol)
if (!await workspace.applyEdit(change)) {
void window.showErrorMessage(`代码阅读:没能把修改写回 ${workspace.asRelativePath(document.uri)}`)
return 'rejected'
}
return 'written'
}
/** 把这几行换成 [text](见 WriteResult)。 */
private async applyEdit(startLine: number, endLine: number, text: string): Promise<WriteResult> {
const { document } = this
const replacement = blockReplacement(line => document.lineAt(line).text, document.lineCount, startLine, endLine, text)
if (!replacement) {
@@ -0,0 +1,139 @@
from collections import Counter
from collections.abc import Sequence
from datetime import date, datetime
from sqlalchemy import exists, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from luffy_agent.acquisition.models import AcquisitionChannel
from luffy_agent.core.filter.repository import FilterableRepository
from luffy_agent.core.time import to_shanghai_date
from luffy_agent.referral.models import ReferralRelation
# luffy_agent.user.models.User 在各方法体内按需局部导入 (非模块顶层):
# user 包 __init__ 会级联加载到 acquisition.service, 而 acquisition 是整个
# 应用最早被 import 的域之一 (见 api_routers.py 的 include 顺序), 顶层导入
# 会与该级联互相等待, 构成循环导入。
class AcquisitionChannelRepository(
FilterableRepository[AcquisitionChannel, dict[str, object], dict[str, object], int]
):
def __init__(self) -> None:
super().__init__(AcquisitionChannel)
self._filter_config = {"field_mapping": {}, "search_fields": ["code", "name"]}
async def get_by_code(
self, db: AsyncSession, *, code: str
) -> AcquisitionChannel | None:
result = await db.execute(
select(AcquisitionChannel).where(AcquisitionChannel.code == code)
)
return result.scalar_one_or_none()
async def get_required_system(
self, db: AsyncSession, *, code: str
) -> AcquisitionChannel:
channel = await self.get_by_code(db, code=code)
if channel is None or not channel.is_system:
raise RuntimeError(f"system acquisition channel missing: {code}")
return channel
async def list_all(self, db: AsyncSession) -> Sequence[AcquisitionChannel]:
"""全量渠道列表, 按 id 升序, 无 limit。
渠道基数小 (系统内置 + 管理员自建), 不做分页; 不可复用
``list_filtered`` 的默认 ``limit=100``, 否则超过 100 个渠道时
管理后台列表与来源分析 report 会被静默截断。
"""
result = await db.execute(
select(AcquisitionChannel).order_by(AcquisitionChannel.id.asc())
)
return result.scalars().all()
async def count_users_by_channel(
self, db: AsyncSession, *, start_at: datetime, end_at: datetime
) -> list[tuple[AcquisitionChannel, int]]:
"""窗口内 (半开区间) 各渠道用户数, 仅含窗口内有用户的渠道。"""
from luffy_agent.user.models import User
from luffy_agent.user.repositories import customer_user_predicate
stmt = (
select(AcquisitionChannel, func.count(User.id))
.join(User, User.acquisition_channel_id == AcquisitionChannel.id)
.where(
User.created_at >= start_at,
User.created_at < end_at,
customer_user_predicate(),
)
.group_by(AcquisitionChannel.id)
)
result = await db.execute(stmt)
return [(channel, count) for channel, count in result.all()]
async def count_daily_by_channel(
self, db: AsyncSession, *, start_at: datetime, end_at: datetime
) -> list[tuple[int, date, int]]:
"""窗口内各渠道逐日 (沪时区自然日) 用户数。"""
from luffy_agent.user.models import User
from luffy_agent.user.repositories import customer_user_predicate
stmt = select(User.acquisition_channel_id, User.created_at).where(
User.created_at >= start_at,
User.created_at < end_at,
customer_user_predicate(),
)
result = await db.execute(stmt)
counter: Counter[tuple[int, date]] = Counter(
(channel_id, to_shanghai_date(created_at))
for channel_id, created_at in result.all()
)
return [
(channel_id, day, count) for (channel_id, day), count in counter.items()
]
async def count_referred_users(
self, db: AsyncSession, *, start_at: datetime, end_at: datetime
) -> int:
"""窗口内曾被邀请过的用户数 (不看邀请关系 status/expire_time)。"""
from luffy_agent.user.models import User
from luffy_agent.user.repositories import customer_user_predicate
stmt = select(func.count(User.id)).where(
User.created_at >= start_at,
User.created_at < end_at,
customer_user_predicate(),
exists(
select(ReferralRelation.id).where(
ReferralRelation.invitee_user_id == User.id
)
),
)
result = await db.execute(stmt)
return result.scalar_one()
async def count_referred_daily(
self, db: AsyncSession, *, start_at: datetime, end_at: datetime
) -> list[tuple[date, int]]:
"""窗口内被邀请用户逐日 (沪时区自然日) 数量。"""
from luffy_agent.user.models import User
from luffy_agent.user.repositories import customer_user_predicate
stmt = select(User.created_at).where(
User.created_at >= start_at,
User.created_at < end_at,
customer_user_predicate(),
exists(
select(ReferralRelation.id).where(
ReferralRelation.invitee_user_id == User.id
)
),
)
result = await db.execute(stmt)
counter = Counter(
to_shanghai_date(created_at) for (created_at,) in result.all()
)
return list(counter.items())
acquisition_channel_repository = AcquisitionChannelRepository()
+51 -19
View File
@@ -17,8 +17,7 @@ function facts(source: string): Promise<Record<number, unknown>> {
/** luffy 桌面端 fetch-runtime.sh 的代码块树(同原型 SHELL_BLOCKS,原型略去的 95-100、116-121 行也在)。 */
const FETCH_TREE: Outline[] = [
{ at: 1, kind: 'interpreter' },
{ at: [2, 13], kind: 'comment' },
{ at: 14, kind: 'options' },
{ at: 14, kind: 'options', notes: [[2, 13]] },
{ at: 16, kind: 'variable' },
{ at: 17, kind: 'variable' },
{ at: 18, kind: 'variable' },
@@ -41,35 +40,28 @@ const FETCH_TREE: Outline[] = [
{ at: 66, kind: 'command' },
{ at: 67, kind: 'command' },
{ at: [68, 71], kind: 'case' },
{ at: 72, kind: 'comment' },
{ at: 73, kind: 'command' },
{ at: [75, 77], kind: 'comment' },
{ at: 78, kind: 'case', kids: [
{ at: 79, kind: 'case', kids: [{ at: 80, kind: 'variable' }, { at: [81, 82], kind: 'command' }, { at: 83, kind: 'comment' }, { at: 84, kind: 'command' }] },
{ at: 86, kind: 'case', kids: [{ at: [87, 88], kind: 'comment' }, { at: 89, kind: 'variable' }, { at: 90, kind: 'command' }, { at: 91, kind: 'command' }] },
{ at: 73, kind: 'command', notes: [72] },
{ at: 78, kind: 'case', notes: [[75, 77]], kids: [
{ at: 79, kind: 'case', kids: [{ at: 80, kind: 'variable' }, { at: [81, 82], kind: 'command' }, { at: 84, kind: 'command', notes: [83] }] },
{ at: 86, kind: 'case', kids: [{ at: 89, kind: 'variable', notes: [[87, 88]] }, { at: 90, kind: 'command' }, { at: 91, kind: 'command' }] },
] },
{ at: [95, 97], kind: 'comment' },
{ at: 98, kind: 'if', kids: [{ at: 99, kind: 'command' }] },
{ at: [102, 104], kind: 'comment' },
{ at: 105, kind: 'if', kids: [
{ at: 98, kind: 'if', notes: [[95, 97]], kids: [{ at: 99, kind: 'command' }] },
{ at: 105, kind: 'if', notes: [[102, 104]], kids: [
{ at: 106, kind: 'variable' },
{ at: [107, 108], kind: 'comment' },
{ at: 109, kind: 'loop', kids: [{ at: 110, kind: 'if' }, { at: [111, 114], kind: 'case' }] },
{ at: 116, kind: 'comment' },
{ at: 117, kind: 'variable' },
{ at: 109, kind: 'loop', notes: [[107, 108]], kids: [{ at: 110, kind: 'if' }, { at: [111, 114], kind: 'case' }] },
{ at: 117, kind: 'variable', notes: [116] },
{ at: 118, kind: 'variable' },
{ at: 119, kind: 'command' },
{ at: 120, kind: 'command' },
{ at: 121, kind: 'command' },
{ at: 122, kind: 'comment' },
{ at: 123, kind: 'pipeline', kids: [{ at: 124, kind: 'command' }, { at: 125, kind: 'command' }] },
{ at: 123, kind: 'pipeline', notes: [122], kids: [{ at: 124, kind: 'command' }, { at: 125, kind: 'command' }] },
] },
{ at: 129, kind: 'command' },
{ at: 130, kind: 'command' },
]
describe('shell 代码块树', () => {
it('fetch-runtime.sh:解释器、选项、变量 / 常量、分情况(短的整条一块,长的每个情况一块子块)、函数(紧贴的注释算进头部)、命令、管道后面的循环主体是子块', async () => {
it('fetch-runtime.sh:解释器、选项、变量 / 常量、分情况(短的整条一块,长的每个情况一块子块)、函数(紧贴的注释算进头部)、命令、管道后面的循环主体是子块;注释挂到后面那块上', async () => {
expect(await tree(FETCH)).toEqual(FETCH_TREE)
})
@@ -218,3 +210,43 @@ describe('shell 命令、名字', () => {
}
})
})
describe('shell 注释挂到它讲的块上', () => {
it('#! 后面隔着空行的是文件的说明;同一层后面最近的那块(隔着空行也算),和函数的说明并存;主体最后的挂到前面那块;文件最后的挂到最后一块', async () => {
const source = [
'#!/usr/bin/env bash',
'# 文件的说明',
'',
'set -e',
'',
'# 讲 fetch:隔着空行',
'',
'# 下载一个文件',
'fetch() {',
' # 先检查',
' if [ -z "$1" ]; then',
' # 只有注释的分支',
' :',
' fi',
' curl -fsSL "$1"',
' # 函数体最后的注释',
'}',
'',
'# 文件最后的注释',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'interpreter' },
{ at: 2, kind: 'comment' },
{ at: 4, kind: 'options' },
{ at: [8, 9], kind: 'function', notes: [6, 19], kids: [
{ at: 11, kind: 'if', notes: [10], kids: [{ at: 13, kind: 'command', notes: [12] }] },
{ at: 15, kind: 'command', notes: [16] },
] },
])
expect((await facts(source))[8]).toMatchObject({ doc: '# 下载一个文件' })
})
it('#! 后面紧挨着第一块代码的注释挂到它上面', async () => {
expect(await tree('#!/bin/sh\n# 出错就停\nset -e\n')).toEqual([{ at: 1, kind: 'interpreter' }, { at: 3, kind: 'options', notes: [2] }])
})
})
+4 -3
View File
@@ -2,7 +2,7 @@ import type { BlockShape, CodeBlock, CommandFacts, CommandWord, DeclaratorFacts,
import type { CommentRules, ListBlocks } from './shared'
import * as path from 'node:path'
import { bashNameReader } from './bashNames'
import { children, compoundShape, leafBlock, leafShape, listBlocks, named } from './shared'
import { attachComments, children, compoundShape, leafBlock, leafShape, listBlocks, named } from './shared'
/** 按习惯当常量用的名字:全大写(可带数字、下划线)。 */
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/
@@ -627,7 +627,7 @@ function bashSteps(root: Node): StepBlocks {
const [only, ...more] = items.filter(item => item.type !== 'comment')
if (only && more.length === 0 && andChain(only))
return { blocks: chainOperands(only).flatMap(stepBlocks), chained: true }
return { blocks: statements(items, -1, true), chained: false }
return { blocks: attachComments(statements(items, -1, true), undefined, false), chained: false }
}
/** bash / sh / zsh 脚本(VS Code 的 languageId 是 shellscript)的语言插件。@param wasmDir 语法包 .wasm 所在目录 */
@@ -636,9 +636,10 @@ export function createBashAdapter(wasmDir: string): LanguageAdapter {
id: 'bash',
languageIds: ['shellscript'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-bash.wasm'),
toBlocks: root => programBlocks(root),
toBlocks: root => attachComments(programBlocks(root)),
steps: root => bashSteps(root),
tokenRole: bashTokenRole,
lineComment: '#',
command: bashCommand,
symbols: root => bashSymbols(root),
displayNames: bashNameReader,
@@ -30,8 +30,7 @@ const NEXT_TREE: Outline[] = [
{ at: 12, kind: 'copy' },
{ at: 13, kind: 'copy' },
{ at: 14, kind: 'variable' },
{ at: [15, 16], kind: 'comment' },
{ at: 17, kind: 'variable' },
{ at: 17, kind: 'variable', notes: [[15, 16]] },
{ at: 18, kind: 'variable' },
{ at: 19, kind: 'run' },
] },
@@ -42,8 +41,7 @@ const NEXT_TREE: Outline[] = [
{ at: 28, kind: 'variable' },
{ at: [30, 31], kind: 'run' },
{ at: [33, 34], kind: 'copy' },
{ at: 35, kind: 'comment' },
{ at: 36, kind: 'copy' },
{ at: 36, kind: 'copy', notes: [35] },
{ at: 38, kind: 'setting' },
{ at: 39, kind: 'setting' },
{ at: 41, kind: 'start' },
@@ -54,36 +52,25 @@ const NEXT_TREE: Outline[] = [
const BACKEND_TREE: Outline[] = [
{ at: [1, 4], kind: 'stage', kids: [
{ at: [6, 7], kind: 'label' },
{ at: [9, 12], kind: 'comment' },
{ at: [13, 25], kind: 'run' },
{ at: [27, 31], kind: 'comment' },
{ at: [32, 33], kind: 'copy' },
{ at: [13, 25], kind: 'run', notes: [[9, 12]] },
{ at: [32, 33], kind: 'copy', notes: [[27, 31]] },
{ at: [34, 35], kind: 'run' },
{ at: 37, kind: 'copy' },
{ at: [39, 41], kind: 'comment' },
{ at: [42, 44], kind: 'run' },
{ at: [42, 44], kind: 'run', notes: [[39, 41]] },
{ at: 45, kind: 'setting' },
{ at: 46, kind: 'variable' },
{ at: 48, kind: 'setting' },
{ at: [50, 53], kind: 'comment' },
{ at: [54, 65], kind: 'copy' },
{ at: 67, kind: 'comment' },
{ at: [68, 79], kind: 'run' },
{ at: 81, kind: 'comment' },
{ at: 82, kind: 'copy' },
{ at: [83, 84], kind: 'comment' },
{ at: [85, 89], kind: 'copy' },
{ at: 91, kind: 'comment' },
{ at: 92, kind: 'run' },
{ at: [94, 95], kind: 'comment' },
{ at: 96, kind: 'run' },
{ at: [54, 65], kind: 'copy', notes: [[50, 53]] },
{ at: [68, 79], kind: 'run', notes: [67] },
{ at: 82, kind: 'copy', notes: [81] },
{ at: [85, 89], kind: 'copy', notes: [[83, 84]] },
{ at: 92, kind: 'run', notes: [91] },
{ at: 96, kind: 'run', notes: [[94, 95]] },
{ at: 98, kind: 'variable' },
{ at: 100, kind: 'comment' },
{ at: 101, kind: 'variable' },
{ at: 101, kind: 'variable', notes: [100] },
{ at: 102, kind: 'variable' },
{ at: 104, kind: 'setting' },
{ at: 106, kind: 'comment' },
{ at: [107, 108], kind: 'health' },
{ at: [107, 108], kind: 'health', notes: [106] },
{ at: 110, kind: 'start' },
] },
]
@@ -238,3 +225,29 @@ describe('dockerfile 嵌着的 shell、命令、名字', () => {
expect(roles).toEqual(expect.arrayContaining(['FROM:keyword', 'node:type', 'AS:keyword', 'deps:variable', 'COPY:keyword', 'a:string', '/b:string', 'PORT:constant', 'X:constant']))
})
})
describe('dockerfile 注释挂到它讲的块上', () => {
it('解析指令后面隔着空行的是文件的说明;阶段里挂到后面那条指令,阶段最后的挂到前面那条;FROM 上面紧贴的照旧是阶段的说明', async () => {
const source = [
'# syntax=docker/dockerfile:1.7',
'',
'# 文件的说明',
'',
'# 基础镜像',
'FROM node:22 AS base',
'# 讲 WORKDIR',
'',
'WORKDIR /app',
'RUN npm ci',
'# 阶段最后的注释',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'directive' },
{ at: 3, kind: 'comment' },
{ at: [5, 6], kind: 'stage', kids: [
{ at: 9, kind: 'setting', notes: [7] },
{ at: 10, kind: 'run', notes: [11] },
] },
])
})
})
@@ -1,7 +1,7 @@
import type { CodeBlock, CommandFacts, CommandForm, CommandWord, DeclaratorFacts, DirectiveFacts, DisplayName, DisplayNameReader, EmbeddedCode, InstructionFlag, LanguageAdapter, LocalSymbol, Node, ParserDirective, SourceRange, SourceSpan, StageFacts, TextBlock, TokenRole } from '../contract'
import * as path from 'node:path'
import { COMMENT_MARK, commentTag } from './displayNames'
import { children, leafBlock, leafShape, named } from './shared'
import { attachComments, children, leafBlock, leafShape, named } from './shared'
/** RUN、shell 写法的 CMD / ENTRYPOINT、HEALTHCHECK 的检查命令交给 shell 的语言插件读。 */
const SHELL = 'shellscript'
@@ -422,8 +422,9 @@ export function createDockerfileAdapter(wasmDir: string): LanguageAdapter {
id: 'dockerfile',
languageIds: ['dockerfile'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-dockerfile.wasm'),
toBlocks: root => dockerBlocks(root),
toBlocks: root => attachComments(dockerBlocks(root)),
tokenRole: dockerTokenRole,
lineComment: '#',
command: dockerCommand,
symbols: root => dockerSymbols(root),
embedded: root => root.descendantsOfType('shell_command').flatMap((node) => {
@@ -8,7 +8,7 @@ import { celeryRule } from '../rules/celery'
import { dataclassRule } from '../rules/dataclass'
import { enumRule } from '../rules/enum'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { flatSegments, inlineOf, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { commentText, flatSegments, inlineOf, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { createPythonAdapter } from './python'
@@ -197,7 +197,7 @@ describe('异常卡片(Python)', () => {
})
})
it('类属性:给一个名字赋值(带不带注解、有没有值),说明取上面的注释块、行尾注释、属性 docstring;连着赋值、解构赋值、方法照常是子块;构造参数去掉 self', async () => {
it('类属性:给一个名字赋值(带不带注解、有没有值),说明取讲它的注释、行尾注释、属性 docstring;连着赋值、解构赋值、方法照常是子块,讲方法的注释挂在方法上;构造参数去掉 self', async () => {
const list = await pyViews(PY_CASES)
expect(lines(list, 'Base')).toEqual([
'名称: Base',
@@ -217,12 +217,11 @@ describe('异常卡片(Python)', () => {
const base = named(list, 'Base')
expect(base.children.map(child => source[child.startLine].trim())).toEqual([
'# 讲方法的注释',
'def describe(self) -> str:',
'a = b = 2',
'x, y = 1, 2',
'def __init__(self, message: str, *, level: int = 1, **extra) -> None:',
'# 收尾',
])
expect(base.children.map(commentText)).toEqual(['讲方法的注释', undefined, undefined, '收尾'])
expect(lines(list, 'Loose').slice(-2)).toEqual(['构造参数: 1 个', ' *args | 未标注 | '])
expect(lines(list, 'Payload')).toEqual(['名称: Payload', '访问级别: 公共', '装饰器: @dataclass', '继承: ValueError', '属性: 1 个', ' field | 整数 | 0'])
expect(named(list, 'Local').attributes).toEqual({ kind: 'exception' })
+64 -20
View File
@@ -22,8 +22,7 @@ const SAMPLE: Outline[] = [
{ at: 6, kind: 'const' },
{ at: 7, kind: 'variable' },
{ at: [10, 11], kind: 'function', kids: [
{ at: 12, kind: 'comment' },
{ at: 13, kind: 'variable' },
{ at: 13, kind: 'variable', notes: [12] },
{ at: 14, kind: 'if', kids: [{ at: 15, kind: 'return' }] },
{ at: 16, kind: 'else', kids: [{ at: 17, kind: 'variable' }] },
{ at: 18, kind: 'else', kids: [{ at: 19, kind: 'statement' }] },
@@ -51,7 +50,7 @@ describe('python 代码块树', () => {
expect(await tree(readFixture('sample.py'))).toEqual(SAMPLE)
})
it('装饰器算进头部;冒号后、主体前的注释归子块;循环的 else 是兄弟块;match 的 case 是子块 if', async () => {
it('装饰器算进头部;冒号后、主体前的注释挂到主体第一条语句上;循环的 else 是兄弟块;match 的 case 是子块 if', async () => {
const source = [
'@cache',
'def load(key):',
@@ -68,8 +67,7 @@ describe('python 代码块树', () => {
].join('\n')
expect(await tree(source)).toEqual([
{ at: [1, 2], kind: 'function', kids: [
{ at: 3, kind: 'comment' },
{ at: 4, kind: 'loop', kids: [{ at: 5, kind: 'variable' }] },
{ at: 4, kind: 'loop', notes: [3], kids: [{ at: 5, kind: 'variable' }] },
{ at: 6, kind: 'else', kids: [{ at: 7, kind: 'statement' }] },
{ at: 8, kind: 'if', kids: [
{ at: 9, kind: 'if', kids: [{ at: 10, kind: 'return' }] },
@@ -79,7 +77,7 @@ describe('python 代码块树', () => {
])
})
it('docstring 只认主体开头;不是开头、也没挂到赋值上的字符串是注释块,f-string、字节串照旧是语句', async () => {
it('docstring 只认主体开头;不是开头、也没挂到赋值上的字符串是注释,挂到后面那条语句上,f-string、字节串照旧是语句', async () => {
const source = [
'def f():',
' x = 1',
@@ -92,9 +90,7 @@ describe('python 代码块树', () => {
expect(await tree(source)).toEqual([
{ at: 1, kind: 'function', kids: [
{ at: 2, kind: 'variable' },
{ at: 3, kind: 'comment' },
{ at: 4, kind: 'comment' },
{ at: 5, kind: 'statement' },
{ at: 5, kind: 'statement', notes: [3, 4] },
{ at: 6, kind: 'statement' },
{ at: 7, kind: 'statement' },
] },
@@ -116,21 +112,18 @@ describe('python 属性 docstring', () => {
})
}
it('attributeDocs.py:模块、类主体里紧跟在赋值后面的字符串并进那块,头部延到字符串结尾;隔着注释、不跟在赋值后面的是注释块', async () => {
it('attributeDocs.py:模块、类主体里紧跟在赋值后面的字符串并进那块,头部延到字符串结尾;隔着注释、不跟在赋值后面的是注释,挂到讲的块上', async () => {
expect(await tree(readFixture('attributeDocs.py'))).toEqual([
{ at: 1, kind: 'comment' },
{ at: [3, 4], kind: 'const' },
{ at: [6, 8], kind: 'variable' },
{ at: [11, 12], kind: 'class', kids: [
{ at: [14, 15], kind: 'variable' },
{ at: [17, 19], kind: 'field' },
{ at: 21, kind: 'comment' },
{ at: 22, kind: 'comment' },
{ at: [17, 19], kind: 'field', notes: [21, 22] },
] },
{ at: 25, kind: 'function', kids: [
{ at: 26, kind: 'variable' },
{ at: [27, 30], kind: 'comment' },
{ at: 31, kind: 'statement' },
{ at: 31, kind: 'statement', notes: [[27, 30]] },
{ at: 32, kind: 'statement' },
{ at: 33, kind: 'return' },
] },
@@ -158,8 +151,8 @@ describe('python 属性 docstring', () => {
' """不算"""',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'class', kids: [{ at: [2, 3], kind: 'field' }, { at: 4, kind: 'comment' }] },
{ at: 5, kind: 'if', kids: [{ at: 6, kind: 'variable' }, { at: 7, kind: 'comment' }] },
{ at: 1, kind: 'class', kids: [{ at: [2, 3], kind: 'field', notes: [4] }] },
{ at: 5, kind: 'if', kids: [{ at: 6, kind: 'variable', notes: [7] }] },
])
expect(await fieldDocs(source)).toEqual({ 2: '"A" "B"' })
})
@@ -180,12 +173,11 @@ describe('python 函数、类的 docstring', () => {
'def g(): "单行"',
].join('\n')
it('算进函数、类头部、不单独成块,它前面的注释也在头部里;后面的注释照旧成块', async () => {
it('算进函数、类头部、不单独成块,它前面的注释也在头部里;后面的注释挂到后面那条语句上', async () => {
expect(await tree(source)).toEqual([
{ at: [1, 2], kind: 'class', kids: [
{ at: [3, 8], kind: 'function', kids: [
{ at: 9, kind: 'comment' },
{ at: 10, kind: 'return' },
{ at: 10, kind: 'return', notes: [9] },
] },
] },
{ at: 11, kind: 'function' },
@@ -513,3 +505,55 @@ describe('python 调用', () => {
expect(await calls('@cache\nclass A(Base):\n pass')).toEqual([])
})
})
describe('python 注释挂到它讲的块上', () => {
it('同一层后面最近的那块(隔着空行也算);主体最后的挂到前面那块;主体里只有注释时挂到父块;文件最后的挂到最后一块', async () => {
const source = [
'"""模块说明。"""',
'',
'import os',
'',
'',
'# 讲 load:隔着空行',
'def load(path):',
' """读出来。"""',
' # 先检查',
' if not path:',
' return None',
' return path',
' # 函数体最后的注释',
'',
'',
'class Empty:',
' """只有说明。"""',
' # 只有注释的主体',
'',
'',
'# 文件最后的注释',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'comment' },
{ at: 3, kind: 'import' },
{ at: [7, 8], kind: 'function', notes: [6], kids: [
{ at: 10, kind: 'if', notes: [9], kids: [{ at: 11, kind: 'return' }] },
{ at: 12, kind: 'return', notes: [13] },
] },
{ at: [16, 17], kind: 'class', notes: [18, 21] },
])
})
it('文件开头的注释和第一块代码隔着空行是文件的说明,紧挨着就挂到它上面;#! 和写编码的那行总是文件的', async () => {
expect(await tree('# 文件的说明\n\nimport os\n')).toEqual([{ at: 1, kind: 'comment' }, { at: 3, kind: 'import' }])
expect(await tree('# 讲导入\nimport os\n')).toEqual([{ at: 2, kind: 'import', notes: [1] }])
expect(await tree('#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n# 讲导入\nimport os\n')).toEqual([
{ at: [1, 2], kind: 'comment' },
{ at: 4, kind: 'import', notes: [3] },
])
})
it('luffy 的 acquisition/repository.py:导入后面隔着两个空行的注释挂到类上,是类卡片的「注释」一行,类的编辑范围连着这几行', async () => {
const source = readFixture('acquisitionRepository.py')
const [klass] = (await tree(source)).filter(block => block.kind === 'class')
expect(klass).toMatchObject({ at: [19, 21], notes: [[13, 16]] })
})
})
+23 -5
View File
@@ -1,8 +1,8 @@
import type { Access, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { Access, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, CommentBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules, ExceptionRules } from './shared'
import * as path from 'node:path'
import { pythonNameReader } from './pythonNames'
import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren, withExceptions } from './shared'
import { attachComments, children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren, withExceptions } from './shared'
/** 常量、类字段只认直接写在模块 / 类主体里的赋值;这两处的声明也才有访问级别。 */
type Scope = 'module' | 'class' | 'other'
@@ -12,8 +12,25 @@ const PARAMETER_SEPARATORS = new Set(['keyword_separator', 'positional_separator
const PATTERNS = new Set(['pattern_list', 'tuple_pattern', 'list_pattern'])
/** 字符串开头(string_start):普通字符串的前缀只有 r / u 或没有;f、t、b 开头的不是。 */
const STRING_PREFIX = /^[ru]?["']/i
// 只有 # 行注释;函数、类的说明是主体开头的 docstring,不是写在前面的注释
const COMMENTS: CommentRules = { lineStyle: () => '#', isDoc: () => false, documents: new Set() }
/** PEP 263 写编码的注释:`# -*- coding: utf-8 -*-`、`# vim: set fileencoding=utf-8 :`。 */
const CODING = /^#.*?coding[:=]\s*[-\w.]+/
/** 文件头两行里写给解释器看的注释:第一行的 `#!`,头两行里写编码的。 */
function isPragma(comment: Node): boolean {
const row = comment.startPosition.row
return (row === 0 && comment.text.startsWith('#!')) || (row <= 1 && CODING.test(comment.text))
}
// 只有 # 行注释;函数、类的说明是主体开头的 docstring,不是写在前面的注释。写给解释器的那两行不和后面的注释连成一块
const COMMENTS: CommentRules = { lineStyle: comment => isPragma(comment) ? '#!' : '#', isDoc: () => false, documents: new Set() }
/** 讲整个文件的注释:模块开头的 docstring(第一条语句,注释不算),写给解释器的那两行。 */
function aboutModule(comment: CommentBlock): boolean {
const { node } = comment
if (node.type === 'comment')
return isPragma(node)
return node.parent?.type === 'module' && sameNode(named(node.parent).find(child => child.type !== 'comment'), node)
}
function statements(items: Node[], scope: Scope, afterRow: number): CodeBlock[] {
const docs = attributeDocs(items, scope)
@@ -636,8 +653,9 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter {
id: 'python',
languageIds: ['python'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-python.wasm'),
toBlocks: root => withExceptions(statements(named(root), 'module', -1), PYTHON_EXCEPTIONS),
toBlocks: root => withExceptions(attachComments(statements(named(root), 'module', -1), aboutModule), PYTHON_EXCEPTIONS),
tokenRole: pythonTokenRole,
lineComment: '#',
iteration: pythonIteration,
call: pythonCall,
displayNames: pythonNameReader,
+60 -17
View File
@@ -1,10 +1,11 @@
import type { BlockView } from '../blockView'
import type { Outline } from '../testSupport'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { blockMarks } from '../diff/viewDiff'
import { rustImplRule } from '../rules/rustImpl'
import { isCrateRoot } from '../rules/rustTree'
import { propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { commentText, outline, propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { rustZhTranslator } from '../translators/rustZh'
import { createRustAdapter } from './rust'
@@ -144,15 +145,15 @@ describe('rust 语言卡片', () => {
expect(mod?.kind).toBe('module')
expect(list.some(block => block.kind === 'statement' && block.code.includes('const LIMIT'))).toBe(false)
expect(list.filter(block => block.kind === 'statement').map(block => block.code)).toEqual(['mod other;'])
expect(mod?.children.map(block => block.kind)).toEqual(['comment', 'import', 'const', 'variable', 'function', 'function', 'statement', 'statement'])
expect(mod?.children.map(block => block.kind)).toEqual(['import', 'const', 'variable', 'function', 'function', 'statement', 'statement'])
expect(viewLines(mod ? [mod] : [])).toEqual([
'module 1',
' 名称: platform',
' 说明: 平台相关。',
' 可见性: 公开',
' comment 3: 读入。',
' import 4',
' import 3',
' 来源: std::io',
' 注释: 读入。',
' 导入项: 1 个',
' Read | —',
' const 5',
@@ -204,7 +205,7 @@ describe('rust 注释', () => {
].join('\n'))
})
it('隔着空行、隔着语句的注释各是一块,行尾的 // 不单独成块', async () => {
it('文件开头隔着空行的注释是文件的说明,别的挂到后面那一项上(中间隔着空行也算),行尾的 // 不单独成块', async () => {
const source = [
'// 第一段',
'// 缩进四格的代码',
@@ -218,15 +219,14 @@ describe('rust 注释', () => {
const list = await views(source)
expect(list.map(block => [block.kind, block.startLine + 1, block.endLine + 1])).toEqual([
['comment', 1, 2],
['comment', 4, 4],
['import', 5, 5],
['comment', 6, 6],
['const', 7, 7],
['import', 4, 5],
['const', 6, 7],
])
expect(markdown(list[0])).toBe('第一段\n 缩进四格的代码')
expect(list.map(commentText)).toEqual([undefined, '第二段', '第三段'])
})
it('/// 并进下一个函数当说明,//! 连着几行合成一块,// 和 /// 不合在一起', async () => {
it('/// 并进下一个函数当说明,//! 连着几行合成一块、是文件的说明,// 和 /// 不合在一起', async () => {
const source = [
'//! 本模块管凭证。',
'//!',
@@ -242,11 +242,10 @@ describe('rust 注释', () => {
const list = await views(source)
expect(list.map(block => [block.kind, block.startLine + 1, block.endLine + 1])).toEqual([
['comment', 1, 3],
['comment', 5, 5],
['function', 6, 9],
['function', 5, 9],
])
expect(markdown(list[0])).toBe('本模块管凭证。\n\n第二段。')
expect(viewLines([list[2]!])).toContain(' 说明: 读出这个账号的凭证。')
expect(viewLines([list[1]!])).toEqual(expect.arrayContaining([' 说明: 读出这个账号的凭证。', ' 注释: 普通注释']))
})
it('连着几行的 /// 是一段说明,块标签拆开放:@param 进参数表、@return 跟在返回后面、@throws 成异常表;结构体的 @param 对到字段', async () => {
@@ -306,8 +305,8 @@ describe('rust 注释', () => {
const list = await views(source)
const mod = list.find(block => block.kind === 'module')
const impl = list.find(block => block.kind === 'rust.impl')
expect(markdown(mod?.children[0])).toBe('一\n二')
expect(markdown(impl?.children.find(block => block.kind === 'comment'))).toBe('三\n\n四')
expect(commentText(mod?.children[0])).toBe('一\n二')
expect(commentText(impl?.children[0])).toBe('三\n\n四')
})
})
@@ -321,10 +320,11 @@ describe('rust 编译属性', () => {
'',
].join('\n')
const list = await views(source, '/repo/apps/desktop/src-tauri/src/main.rs')
expect(list.map(block => block.kind)).toEqual(['comment', 'attribute', 'function'])
expect(list.map(block => block.kind)).toEqual(['attribute', 'function'])
expect(viewLines([card(list, 'attribute')])).toEqual([
'attribute 2',
'attribute 1',
' 属性: windows_subsystem = "windows"',
' 注释: Prevents additional console window on Windows in release, DO NOT REMOVE!!',
' 生效条件: 非 调试构建',
' 作用范围: 整个 crate',
' 说明: 在 Windows 上编译为图形界面程序(windows 子系统),启动时不弹出控制台窗口',
@@ -375,3 +375,46 @@ describe('rust 编译属性', () => {
expect(['/p/src/credentials.rs', '/p/src/tests/helpers.rs', '/p/src/bin/tool/args.rs'].map(isCrateRoot)).toEqual([false, false, false])
})
})
describe('rust 注释挂到它讲的项上', () => {
const tree = (source: string): Promise<Outline[]> => withBlocks(adapter, 'rust', source, outline)
it('同一层后面最近的那项(隔着空行也算),和 /// 说明并存;只有注释的 mod 挂到 mod 上;impl 最后的挂到前面那项;文件最后的挂到最后一项;//! 是文件的说明', async () => {
const source = [
'//! 凭证存储。',
'',
'use std::io;',
'',
'// 讲 load:隔着空行',
'',
'/// 读出凭证。',
'pub fn load() -> Option<String> { None }',
'',
'mod empty {',
' // 只有注释的模块',
'}',
'',
'impl Store {',
' // 讲 get',
' fn get(&self) {}',
' // impl 最后的注释',
'}',
'',
'// 文件最后的注释',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'comment' },
{ at: 3, kind: 'import' },
{ at: [7, 8], kind: 'function', notes: [5] },
{ at: 10, kind: 'module', notes: [11] },
{ at: 14, kind: 'impl', notes: [20], kids: [{ at: 16, kind: 'function', notes: [15, 17] }] },
])
})
it('紧挨着第一项的注释挂到它上面;mod 里的 //! 交给那个 mod', async () => {
expect(await tree('// 讲导入\nuse std::io;\n\nmod inner {\n //! 模块自己的\n fn a() {}\n}\n')).toEqual([
{ at: 2, kind: 'import', notes: [1] },
{ at: 4, kind: 'module', notes: [5], kids: [{ at: 6, kind: 'function' }] },
])
})
})
+9 -3
View File
@@ -1,8 +1,8 @@
import type { Access, AttributeFacts, AttributeItem, CfgPredicate, ClassFacts, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, ImportItem, LanguageAdapter, Node, ParameterFacts, SourceSpan, TokenRole, TraitBlock } from '../contract'
import type { Access, AttributeFacts, AttributeItem, CfgPredicate, ClassFacts, CodeBlock, CommentBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, ImportItem, LanguageAdapter, Node, ParameterFacts, SourceSpan, TokenRole, TraitBlock } from '../contract'
import type { CommentRules } from './shared'
import * as path from 'node:path'
import { leadingAttributes, visibilityFacts } from '../rules/rustTree'
import { children, compoundShape, extendHeader, headerSpan, isCommentNode, leafBlock, leafShape, listBlocks, named } from './shared'
import { attachComments, children, compoundShape, extendHeader, headerSpan, isCommentNode, leafBlock, leafShape, listBlocks, named } from './shared'
const STORAGE = new Set(['pub', 'async', 'const', 'static', 'mut', 'let'])
@@ -11,8 +11,9 @@ export function createRustAdapter(wasmDir: string): LanguageAdapter {
id: 'rust',
languageIds: ['rust'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-rust.wasm'),
toBlocks: root => topBlocks(named(root), -1),
toBlocks: root => attachComments(topBlocks(named(root), -1), aboutModule),
tokenRole: rustTokenRole,
lineComment: '//',
}
}
@@ -34,6 +35,11 @@ const COMMENTS: CommentRules = {
documents: new Set(['function', 'class', 'trait', 'impl', 'module', 'enum', 'const', 'variable']),
}
/** 讲所在模块或 crate 的内部文档注释(`//!`、`/*!`):写在文件开头是文件的说明,写在 `mod` 身体里交给那个模块。 */
function aboutModule(comment: CommentBlock): boolean {
return named(comment.node).some(child => child.type === 'inner_doc_comment_marker')
}
/** 文档注释记号:外部的 `/`(块注释是 `*`)、内部的 `!`;普通注释没有。 */
function docMarker(comment: Node): string {
return named(comment).find(child => child.type === 'outer_doc_comment_marker' || child.type === 'inner_doc_comment_marker')?.text ?? ''
+98 -30
View File
@@ -1,4 +1,4 @@
import type { BaseFacts, BlockShape, ClassBlock, ClassFacts, CodeBlock, DocumentedBlock, ExceptionBlock, FieldFacts, Node, ParameterFacts, SourceSpan, TextKind } from '../contract'
import type { BaseFacts, BlockShape, ClassBlock, ClassFacts, CodeBlock, CommentBlock, DocumentedBlock, ExceptionBlock, FieldFacts, Node, ParameterFacts, SourceSpan, TextKind } from '../contract'
/** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */
@@ -125,7 +125,7 @@ function documentable(block: CodeBlock, kinds: ReadonlySet<string>): block is Do
}
/**
* 一串语句 → 块。注释单独成块,相邻行同一写法的行注释合成一块(见 CommentRules.lineStyle);
* 一串语句 → 块。注释先成注释块(建好整棵树后由 attachComments 挂到它讲的块上),相邻行同一写法的行注释合成一块(见 CommentRules.lineStyle);
* 和前一条语句(或头部)同一行的注释是行尾注释,跟着那一行显示,不单独成块。
* 文档注释(连着几行的算一段)和下一条语句之间只有空白、下一条语句是 CommentRules.documents 里的块时,它是这一块的说明,不单独成块;
* 中间隔着的装饰器(不出块的语句)不算隔开。
@@ -193,6 +193,88 @@ export function listBlocks(
return out
}
// ─── 注释挂到它讲的块上 ───────────────────────────────────────────────────────
export function isCommentBlock(block: CodeBlock): block is CommentBlock {
return block.kind === 'comment'
}
/** 文件开头算不上「第一块代码」的块:`#!` 写的解释器、Dockerfile 的解析指令。 */
const PREAMBLE_KINDS = new Set<string>(['interpreter', 'directive'])
/** 块挂上注释,和它已有的(子块交上来的)合在一起照源码顺序排。 */
function withComments(block: CodeBlock, comments: readonly CommentBlock[]): CodeBlock {
if (comments.length === 0)
return block
const all = [...(block.comments ?? []), ...comments].sort((a, b) => a.header.startIndex - b.header.startIndex)
return { ...block, comments: all }
}
/**
* 一层兄弟块里的注释块挂到它讲的块上:同一层里它后面最近的一块代码(中间隔空行也算),后面没有代码就挂到前面最近的一块;
* 这一层一块代码都没有时交给父块(up)。aboutScope 认出的(讲所在模块的,Rust 的 `//!`)也交给父块。先挂好各块的子块。
*/
function attachIn(list: readonly CodeBlock[], aboutScope: (comment: CommentBlock) => boolean): { blocks: CodeBlock[], up: CommentBlock[] } {
const up: CommentBlock[] = []
const done: CodeBlock[] = []
for (const block of list) {
if (isCommentBlock(block)) {
if (aboutScope(block))
up.push(block)
else
done.push(block)
continue
}
const inner = attachIn(block.children, aboutScope)
const changed = inner.blocks.length !== block.children.length || inner.blocks.some((child, i) => child !== block.children[i])
done.push(withComments(changed ? { ...block, children: inner.blocks } : block, inner.up))
}
if (!done.some(block => !isCommentBlock(block)))
return { blocks: [], up: [...up, ...done.filter(isCommentBlock)] }
const out: CodeBlock[] = []
let pending: CommentBlock[] = []
for (const block of done) {
if (isCommentBlock(block)) {
pending.push(block)
continue
}
out.push(withComments(block, pending))
pending = []
}
out[out.length - 1] = withComments(out[out.length - 1], pending)
return { blocks: out, up }
}
/**
* 注释挂到它讲的块上(BlockShape.comments),不再单独成块;语言插件建好整棵树后调一次。见 attachIn。
* 单独成块的只有文件开头的说明:第一块代码(`#!` 解释器、解析指令不算)之前的注释,和第一块代码之间隔着空行的;
* 紧挨着第一块代码(中间没空行)的那几段照常挂到它上面。aboutScope 认出的讲整个文件的注释(Python 模块开头的 docstring、
* Rust 的 `//!`、JSDoc 的 `@file`)写在文件开头时总是文件的说明,写在模块里时交给那个模块。
* file 为假时(嵌在别的语言里的一段代码)没有文件开头的说明,一块代码都没有时注释照旧单独成块。
*/
export function attachComments(blocks: readonly CodeBlock[], aboutScope: (comment: CommentBlock) => boolean = () => false, file = true): CodeBlock[] {
const first = blocks.findIndex(block => !isCommentBlock(block) && !PREAMBLE_KINDS.has(block.kind))
if (first < 0)
return [...blocks]
let header = file ? first : 0
for (let start = blocks[first].header.startRow; header > 0; header--) {
const block = blocks[header - 1]
if (!isCommentBlock(block) || aboutScope(block) || block.header.endRow + 1 < start)
break
start = block.header.startRow
}
const { blocks: rest, up } = attachIn(blocks.slice(header), aboutScope)
return [...blocks.slice(0, header), ...withScopeComments(rest, up)]
}
/** 文件这一层交上来的讲所在模块的注释(不在文件开头的)挂到第一块代码上。 */
function withScopeComments(blocks: CodeBlock[], up: readonly CommentBlock[]): CodeBlock[] {
if (up.length === 0)
return blocks
const at = blocks.findIndex(block => !isCommentBlock(block))
return at < 0 ? [...up, ...blocks] : blocks.map((block, i) => i === at ? withComments(block, up) : block)
}
/** 同一个节点:web-tree-sitter 每次取子节点都会新建对象,只能按位置和类型比。 */
export function sameNode(a: Node | null | undefined, b: Node): boolean {
return !!a && a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type
@@ -216,15 +298,6 @@ function classBlocks(blocks: readonly CodeBlock[]): ClassBlock[] {
return blocks.flatMap(block => [...(block.kind === 'class' ? [block] : []), ...classBlocks(block.children)])
}
/** 紧挨在 child 前面的注释块(连着几块都算,中间没隔别的子块),照源码顺序:讲的是它下面的这个 child。 */
function leadingComments(children: readonly CodeBlock[], child: CodeBlock): CodeBlock[] {
const at = children.indexOf(child)
let from = at
while (from > 0 && children[from - 1].kind === 'comment')
from--
return children.slice(from, at)
}
/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);Python 当注释用的字符串是那个字符串。 */
function commentNodes(block: CodeBlock): Node[] {
if (!isCommentNode(block.node))
@@ -255,29 +328,24 @@ function ownDoc(block: CodeBlock): Node | undefined {
}
}
/** 能重写的那段注释:注释块是整块;函数、类、枚举、异常、常量、变量是它的说明。没有说明就没有。 */
export function commentSpan(block: CodeBlock): { startLine: number, endLine: number } | undefined {
if (block.kind === 'comment')
return { startLine: block.header.startRow, endLine: block.header.endRow }
const doc = ownDoc(block)
if (!doc)
return undefined
return { startLine: doc.startPosition.row, endLine: doc.endPosition.row }
}
/** 类属性的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */
function fieldDocs(children: readonly CodeBlock[], block: CodeBlock): Node[] {
/** 类属性的说明,照源码顺序:讲它的注释(BlockShape.comments)、它这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */
function fieldDocs(block: CodeBlock): Node[] {
const trailing = block.node.nextNamedSibling
const doc = ownDoc(block)
return [
...leadingComments(children, block).flatMap(commentNodes),
...(block.comments ?? []).flatMap(commentNodes),
...(trailing && isCommentNode(trailing) && trailing.startPosition.row === block.node.endPosition.row ? [trailing] : []),
...(doc ? [doc] : []),
]
].sort((a, b) => a.startIndex - b.startIndex)
}
/** 块连同讲它的注释各段的范围。 */
function spansOf(block: CodeBlock): SourceSpan[] {
return [block.header, nodeSpan(block.node), ...(block.comments ?? []).map(comment => comment.header)]
}
/**
* 类块换成异常块:类属性连同紧挨在它前面的注释块并进属性表,构造函数(写了几个取最后一个,重载的实现写在最后)的参数进属性表,
* 类块换成异常块:类属性连同讲它的注释并进属性表,构造函数(写了几个取最后一个,重载的实现写在最后)的参数进属性表,
* 留下的子块(方法、构造函数本身、别的语句)不管写在哪都照源码顺序留作子块;头部是整个类(到它连同各子块的最后一行),
* 「编辑」时整段一起改,webview 在它编辑时不画留下的子块。
*/
@@ -289,19 +357,19 @@ function exceptionBlock(block: ClassBlock, rules: ExceptionRules): ExceptionBloc
for (const child of children) {
const field = rules.field(child)
if (field) {
fields.push({ ...field, docs: fieldDocs(children, child) })
fields.push({ ...field, docs: fieldDocs(child) })
members.push(child)
}
parameters = rules.constructorParameters(child) ?? parameters
}
const absorbed = new Set(members.flatMap(member => [...leadingComments(children, member), member]))
const ends = [block.header, nodeSpan(block.node), ...children.flatMap(child => [child.header, nodeSpan(child.node)])]
const ends = [block.header, nodeSpan(block.node), ...children.flatMap(spansOf)]
const last = ends.reduce((end, span) => span.endIndex > end.endIndex ? span : end)
return {
kind: 'exception',
node: block.node,
header: { ...block.header, endIndex: last.endIndex, endRow: last.endRow },
children: children.filter(child => !absorbed.has(child)),
children: children.filter(child => !members.includes(child)),
...(block.comments && { comments: block.comments }),
...(block.attributes && { attributes: block.attributes }),
facts: {
name: facts.name,
@@ -131,7 +131,7 @@ describe('typeScript 代码块树', () => {
])
})
it('类:方法 → function、字段 → field、注释 → comment,装饰器算进成员头部', async () => {
it('类:方法 → function、字段 → field,注释挂到它讲的成员上,装饰器算进成员头部', async () => {
const source = [
'export class Store extends Base {',
' // 缓存',
@@ -147,8 +147,7 @@ describe('typeScript 代码块树', () => {
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'class', kids: [
{ at: 2, kind: 'comment' },
{ at: 3, kind: 'field' },
{ at: 3, kind: 'field', notes: [2] },
{ at: [4, 5], kind: 'function', kids: [{ at: 6, kind: 'return' }] },
{ at: 8, kind: 'function', kids: [{ at: 9, kind: 'statement' }] },
] },
@@ -208,7 +207,7 @@ describe('typeScript 代码块树', () => {
])
})
it('相邻行的行注释合成一块,空行隔开的分开;行尾注释不单独成块', async () => {
it('相邻行的行注释合成一段,空行隔开的分开;文件开头隔着空行的是文件的说明,紧挨着代码的挂到代码上;行尾注释不单独成块', async () => {
const source = [
'// a',
'// b',
@@ -219,8 +218,7 @@ describe('typeScript 代码块树', () => {
].join('\n')
expect(await tree(source, 'javascript')).toEqual([
{ at: [1, 2], kind: 'comment' },
{ at: 4, kind: 'comment' },
{ at: 5, kind: 'const' },
{ at: 5, kind: 'const', notes: [4] },
{ at: 6, kind: 'statement' },
])
})
@@ -279,7 +277,7 @@ describe('typeScript 函数的 JSDoc', () => {
'function i() {}',
].join('\n')
it('紧贴在函数块前面的算进函数头部、不单独成块;前面不是函数块、没紧贴、不是 JSDoc 的照旧是注释块', async () => {
it('紧贴在函数、常量块前面的算进它的头部、不单独成块;前面不是能带说明的块、没紧贴、不是 JSDoc 的是普通注释,挂到后面那块上', async () => {
expect(await tree(source)).toEqual([
{ at: [1, 2], kind: 'function' },
{ at: [3, 5], kind: 'function' },
@@ -287,22 +285,15 @@ describe('typeScript 函数的 JSDoc', () => {
{ at: [8, 9], kind: 'function', kids: [{ at: [10, 11], kind: 'function' }] },
{ at: 13, kind: 'class', kids: [
{ at: [14, 16], kind: 'function' },
{ at: 17, kind: 'comment' },
{ at: 18, kind: 'field' },
{ at: 18, kind: 'field', notes: [17] },
] },
{ at: 20, kind: 'comment' },
{ at: 21, kind: 'const' },
{ at: 22, kind: 'comment' },
{ at: 23, kind: 'function' },
{ at: 24, kind: 'comment' },
{ at: 25, kind: 'function' },
{ at: 26, kind: 'comment' },
{ at: 27, kind: 'comment' },
{ at: 28, kind: 'function' },
{ at: [20, 21], kind: 'const' },
{ at: 23, kind: 'function', notes: [22] },
{ at: 25, kind: 'function', notes: [24] },
{ at: 28, kind: 'function', notes: [26, 27] },
{ at: 29, kind: 'statement' },
{ at: 30, kind: 'function' },
{ at: 31, kind: 'comment' },
{ at: 32, kind: 'function' },
{ at: 32, kind: 'function', notes: [31] },
])
})
@@ -314,6 +305,7 @@ describe('typeScript 函数的 JSDoc', () => {
expect(found[8]).toMatchObject({ name: 'c', doc: '/** 函数值的变量 */' })
expect(found[10]).toMatchObject({ name: 'd', doc: '/** 嵌套 */' })
expect(found[14]).toMatchObject({ name: 'm', doc: '/** 方法,隔着装饰器 */', decorators: ['@memo()'] })
expect(found[20]).toMatchObject({ doc: '/** 常量 */', declarators: [{ name: 'y' }] })
for (const line of [23, 25, 28, 30, 32])
expect(found[line], `L${line}`).not.toHaveProperty('doc')
})
@@ -498,8 +490,7 @@ describe('typeScript 类', () => {
expect(await tree(readFixture('classes.ts.txt'))).toEqual([
{ at: 1, kind: 'import' },
{ at: [3, 9], kind: 'class', kids: [
{ at: 10, kind: 'comment' },
{ at: 11, kind: 'field' },
{ at: 11, kind: 'field', notes: [10] },
{ at: 13, kind: 'function' },
{ at: 15, kind: 'class', kids: [{ at: 16, kind: 'function' }] },
] },
@@ -632,3 +623,48 @@ describe('typeScript 调用', () => {
expect(await calls('new Date')).toEqual([])
})
})
describe('typeScript 注释挂到它讲的块上', () => {
it('同一层后面最近的那块(隔着空行也算),和 JSDoc 说明并存;主体最后的挂到前面那块;主体里只有注释时挂到父块;文件最后的挂到最后一块;@file 是文件的说明', async () => {
const source = [
'/**',
' * @file 读配置的工具。',
' */',
'import { readFile } from \'node:fs/promises\'',
'',
'// 讲 load:隔着空行',
'',
'/** 读出配置。 */',
'export async function load(path: string) {',
' // 先检查',
' if (!path) {',
' // 只有注释的分支',
' }',
' return readFile(path)',
' // 函数体最后的注释',
'}',
'',
'class Empty {',
' // 只有注释的类',
'}',
'',
'// 文件最后的注释',
].join('\n')
expect(await tree(source)).toEqual([
{ at: [1, 3], kind: 'comment' },
{ at: 4, kind: 'import' },
{ at: [8, 9], kind: 'function', notes: [6], kids: [
{ at: 11, kind: 'if', notes: [10, 12] },
{ at: 14, kind: 'return', notes: [15] },
] },
{ at: 18, kind: 'class', notes: [19, 22] },
])
})
it('文件开头的注释和第一块代码隔着空行是文件的说明,紧挨着就挂到它上面', async () => {
expect(await tree('// 文件的说明\n\n// 紧挨着第一块代码\nconst x = 1\n')).toEqual([
{ at: 1, kind: 'comment' },
{ at: 4, kind: 'const', notes: [3] },
])
})
})
+12 -4
View File
@@ -1,7 +1,7 @@
import type { Access, BaseFacts, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { Access, BaseFacts, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, CommentBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules, ExceptionRules } from './shared'
import * as path from 'node:path'
import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren, withExceptions } from './shared'
import { attachComments, children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren, withExceptions } from './shared'
import { typescriptNameReader } from './typescriptNames'
const GRAMMARS: Record<string, string> = {
@@ -22,7 +22,14 @@ const COMMENTS: CommentRules = {
lineStyle: comment => comment.text.startsWith('//') ? '//' : undefined,
// `/**/` 是空的块注释,不是 JSDoc
isDoc: comment => comment.text.startsWith('/**') && !comment.text.startsWith('/**/'),
documents: new Set(['function', 'class', 'enum']),
documents: new Set(['function', 'class', 'enum', 'const', 'variable']),
}
/** 讲整个文件的 JSDoc:写了 `@file`、`@fileoverview` 或 `@overview`。 */
const FILE_TAG = /^\/\*\*[\s\S]*@(?:file|fileoverview|overview)\b/
function aboutFile(comment: CommentBlock): boolean {
return FILE_TAG.test(comment.node.text)
}
/** 带语句块主体的函数值(箭头函数 / 函数表达式)的主体。 */
@@ -639,8 +646,9 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter {
throw new Error(`TypeScript 语言插件不支持 ${languageId}`)
return path.join(wasmDir, file)
},
toBlocks: (root, _source, languageId) => withExceptions(blockReader(TYPED_LANGUAGES.has(languageId))(root), TYPESCRIPT_EXCEPTIONS),
toBlocks: (root, _source, languageId) => withExceptions(attachComments(blockReader(TYPED_LANGUAGES.has(languageId))(root), aboutFile), TYPESCRIPT_EXCEPTIONS),
tokenRole: typescriptTokenRole,
lineComment: '//',
call: typescriptCall,
displayNames: typescriptNameReader,
}
+17 -13
View File
@@ -97,13 +97,13 @@ describe('库规则:启用', () => {
})
describe('库规则:认领', () => {
it('认领的块换成替身:种类、badge、编辑范围是整个类;规则没并进来的子块(夹在中间的注释、方法)照源码顺序留下', async () => {
it('认领的块换成替身:种类、badge、编辑范围是整个类;规则没并进来的子块(方法)照源码顺序留下,讲并进来的子块的注释跟着并进来', async () => {
const rule: LibraryRule = {
...cardRule('orm'),
claim: (block) => {
if (block.kind !== 'class')
return undefined
const [table, , column] = block.children
const [table, column] = block.children
return { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [{ label: '名称', value: [{ text: 'User', role: 'type' }] }], absorbs: [table, column] }
},
}
@@ -117,7 +117,7 @@ describe('库规则:认领', () => {
attributes: { kind: 'orm.card', access: 'public' },
properties: [{ label: '名称' }],
})
expect(card.children.map(child => [child.id, child.kind, child.startLine])).toEqual([['1.0', 'comment', 4], ['1.1', 'function', 7]])
expect(card.children.map(child => [child.id, child.kind, child.startLine])).toEqual([['1.0', 'function', 7]])
})
it('两个字段中间写着的方法:预览里照常是卡片的子块;卡片「编辑」写回整个类', async () => {
@@ -166,7 +166,7 @@ describe('库规则:认领', () => {
const alone = await views(MODEL, [throwing])
expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 2, endLine: 8 })
expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'comment', 'variable', 'function'])
expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'variable', 'function'])
})
it('替身不合约定(种类 id 没带规则 id、并进来的不是子块):记下来,照常显示', async () => {
@@ -183,15 +183,20 @@ describe('库规则:认领', () => {
])
})
it('略去的块不出块,后面的块 id 接着往下数;还有子块的块不能略去,记下来、照常显示', async () => {
it('略去的块不出块,后面的块 id 接着往下数;还有子块、挂着注释的块不能略去,记下来、照常显示', async () => {
const omitting = (kind: string): LibraryRule => ({ ...cardRule('omit'), claim: block => block.kind === kind ? { omit: true } : undefined })
const list = await views(MODEL, [omitting('import')])
expect(list.map(view => [view.id, view.kind])).toEqual([['0', 'class']])
expect(list[0].children.map(child => child.id)).toEqual(['0.0', '0.1', '0.2', '0.3'])
expect(list[0].children.map(child => child.id)).toEqual(['0.0', '0.1', '0.2'])
const reports: string[] = []
expect((await views(MODEL, [omitting('class')], reports)).map(view => view.kind)).toEqual(['import', 'class'])
expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块'])
const commented: string[] = []
const [, kept] = await views(MODEL, [omitting('variable')], commented)
expect(kept.children.map(child => [child.kind, child.startLine])).toEqual([['variable', 4], ['function', 7]])
expect(commented).toEqual(['库规则 omit 给第 6 行的 variable 块的替身不合约定,这块照常显示:要略去的块挂着注释'])
})
it('挂在卡片子行上的子块:照常转成块,记下挂在哪一行的哪个子行;不是留下的子块、没有那一行 / 子行、放了两处的不合约定', async () => {
@@ -205,15 +210,15 @@ describe('库规则:认领', () => {
? { kind: `${id}.card`, badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows, absorbs: block.children.slice(0, 1), slots: slots(block) }
: undefined,
})
const [, card] = await views(MODEL, [slotting(block => [{ block: block.children[2], row: 0, item: 0 }, { block: block.children[3], row: 1, item: 0 }])])
expect(card.children.map(child => [child.kind, child.slot])).toEqual([['comment', undefined], ['variable', { row: 0, item: 0 }], ['function', { row: 1, item: 0 }]])
const [, card] = await views(MODEL, [slotting(block => [{ block: block.children[1], row: 0, item: 0 }, { block: block.children[2], row: 1, item: 0 }])])
expect(card.children.map(child => [child.kind, child.slot])).toEqual([['variable', { row: 0, item: 0 }], ['function', { row: 1, item: 0 }]])
const reports: string[] = []
const bad = await views(MODEL, [
slotting(block => [{ block: block.children[0], row: 1, item: 0 }], 'absorbed'),
slotting(block => [{ block: block.children[3], row: 5, item: 0 }], 'row'),
slotting(block => [{ block: block.children[3], row: 1, item: 1 }], 'item'),
slotting(block => [{ block: block.children[3], row: 1, item: 0 }, { block: block.children[3], row: 0, item: 0 }], 'twice'),
slotting(block => [{ block: block.children[2], row: 5, item: 0 }], 'row'),
slotting(block => [{ block: block.children[2], row: 1, item: 1 }], 'item'),
slotting(block => [{ block: block.children[2], row: 1, item: 0 }, { block: block.children[2], row: 0, item: 0 }], 'twice'),
], reports)
expect(bad[1].kind).toBe('class')
expect(reports.map(report => report.replace(/^.*照常显示:/, ''))).toEqual([
@@ -235,7 +240,7 @@ describe('库规则:认领', () => {
expect(recognized).toMatchObject({ kind: 'enum', startLine: 2, endLine: 8, attributes: { kind: 'enum', access: 'public' } })
expect(recognized.badge).toBeUndefined()
expect(propertyLines('properties' in recognized ? recognized.properties : [])).toEqual(['名称: User', '访问级别: 公共', '成员: 无'])
expect(recognized.children.map(child => child.kind)).toEqual(['comment', 'variable', 'function'])
expect(recognized.children.map(child => child.kind)).toEqual(['variable', 'function'])
const reports: string[] = []
const stranger: CodeBlock[] = []
@@ -250,7 +255,6 @@ describe('库规则:认领', () => {
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values).toEqual([
{ id: 'import', name: '导入', count: 1 },
{ id: 'orm.card', name: 'orm 卡片', count: 1 },
{ id: 'comment', name: '注释', count: 1 },
{ id: 'variable', name: '变量', count: 1 },
{ id: 'function', name: '函数', count: 1 },
{ id: 'return', name: '返回', count: 1 },
+1 -1
View File
@@ -41,7 +41,7 @@ export interface Claim {
/** 替身不合 RuleBlock 的约定时说哪儿不对。 */
function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): string | undefined {
if ('omit' in claimed)
return block.children.length > 0 ? '要略去的块还有子块' : undefined
return block.children.length > 0 ? '要略去的块还有子块' : block.comments ? '要略去的块挂着注释' : undefined
if (claimed.absorbs.some(child => !block.children.includes(child)))
return '要并进来的块不是这块的子块'
if ('as' in claimed)
+27 -1
View File
@@ -2,7 +2,8 @@ import type { runClaudeLines } from '../../cc/spawnClaude'
import type { AskDeps, AskRun } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { askArgs, askClaude, askFailure } from './claudeAsk'
import { COMMENT_SCHEMA } from '../commentRewrite'
import { askArgs, askClaude, askCommentBody, askFailure } from './claudeAsk'
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
@@ -168,3 +169,28 @@ describe('失败了给人看的话', () => {
expect(askFailure(new ClaudeCancelledError(''))).toBe('Claude Code 没答成:Claude 调用已停止')
})
})
describe('重写注释问一次', () => {
const structured = (body: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: 's1' })
it('只读、按 JSON Schema 交结构化结果:参数在只读那套后面加 --json-schema,stdin 里只有那一句提示;返回结构化结果里的正文', async () => {
const { deps, calls } = fakeClaude([{ lines: [structured('按名字找文件。')] }])
const events: unknown[] = []
const body = await askCommentBody({ prompt: '/ecc:writing-code-comments src/a.py:3-8', profilePath: '/p/sonnet.json', cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event) }, deps)
expect(body).toBe('按名字找文件。')
expect(calls[0].args).toEqual([...askArgs({ profilePath: '/p/sonnet.json' }), '--json-schema', COMMENT_SCHEMA])
expect(calls[0].args).toEqual(expect.arrayContaining(['--tools', 'Read,Grep,Glob', '--permission-mode', 'dontAsk']))
expect(JSON.parse(COMMENT_SCHEMA)).toEqual({ type: 'object', properties: { body: { type: 'string' } }, required: ['body'], additionalProperties: false })
expect(sent(calls[0])).toEqual({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [] })
expect(calls[0].cwd).toBe('/work')
})
it('只回了一段话、没交结构化结果:没有正文;Claude Code 报错、没给结果:抛错', async () => {
const free = fakeClaude([{ lines: [result('我改好了。', 's1')] }])
expect(await askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, free.deps)).toBeUndefined()
const failing = fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }])
await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, failing.deps)).rejects.toThrow('额度用完了')
const empty = fakeClaude([{ lines: [] }])
await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, empty.deps)).rejects.toThrow('没给出结果')
})
})
+47 -1
View File
@@ -4,8 +4,10 @@
*/
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { CommentEvent } from '../commentRewrite'
import type { AnswerEvent } from './answerStream'
import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
import { COMMENT_SCHEMA, createCommentParser } from '../commentRewrite'
import { createAnswerParser } from './answerStream'
/** 只给读、搜文件的工具。 */
@@ -17,8 +19,9 @@ export const ASK_TIMEOUT_MS = 5 * 60_000
* 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
* 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions);
* 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。
* jsonSchema:要模型按这个 JSON Schema 交结构化结果(`--json-schema`,结果在 result 行的 structured_output 里)。
*/
export function askArgs(options: { profilePath?: string, resume?: string } = {}): string[] {
export function askArgs(options: { profilePath?: string, resume?: string, jsonSchema?: string } = {}): string[] {
return [
...(options.profilePath ? ['--settings', options.profilePath] : []),
'--tools',
@@ -36,6 +39,7 @@ export function askArgs(options: { profilePath?: string, resume?: string } = {})
'stream-json',
'--verbose',
'--include-partial-messages',
...(options.jsonSchema ? ['--json-schema', options.jsonSchema] : []),
]
}
@@ -112,6 +116,48 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom
return once(run.prompt)
}
export interface CommentAsk {
/** 只有技能命令和代码块在哪的一句话(commentRewritePrompt) */
prompt: string
profilePath?: string
cwd: string
signal: AbortSignal
/** 重写的过程中:正文写出来的字、正在读什么 */
onEvent: (event: Extract<CommentEvent, { kind: 'text' | 'activity' }>) => void
}
/**
* 「重写注释」问一次:同问 AI 一样只读(模型能读那个文件,改不了,写回归扩展),按 COMMENT_SCHEMA 交结构化结果。
* 返回结果里的注释正文;只回了一段话、没交结构化结果时是 undefined。失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。
*/
export async function askCommentBody(ask: CommentAsk, deps: AskDeps = DEFAULT_DEPS): Promise<string | undefined> {
const parse = createCommentParser()
let result: { body?: string } | undefined
let failure: string | undefined
await deps.runLines({
args: askArgs({ profilePath: ask.profilePath, jsonSchema: COMMENT_SCHEMA }),
input: buildStreamJsonLine(ask.prompt, []),
cwd: ask.cwd,
signal: ask.signal,
timeoutMs: ASK_TIMEOUT_MS,
onLine: (line) => {
for (const event of parse(line)) {
if (event.kind === 'result')
result = event
else if (event.kind === 'error')
failure = event.message
else
ask.onEvent(event)
}
},
})
if (failure !== undefined)
throw new ClaudeError(failure, '')
if (!result)
throw new ClaudeError('Claude Code 结束了,但没给出结果', '')
return result.body
}
const AUTH_HINT = /\/login|log ?in|auth|401|403|api key|unauthori[sz]ed|credential|oauth/i
/** 问失败了,给人看的一句话。 */
+5 -23
View File
@@ -27,24 +27,6 @@ export function annotatedAssignments(children: readonly CodeBlock[]): AnnotatedA
return found
}
/** 紧挨在 child 前面的注释块(连着几块都算,中间没隔别的子块),照源码顺序:讲的是它下面的这个 child。 */
export function leadingComments(children: readonly CodeBlock[], child: CodeBlock): CodeBlock[] {
const at = children.indexOf(child)
let from = at
while (from > 0 && children[from - 1].kind === 'comment')
from--
return children.slice(from, at)
}
/**
* 要并进卡片的子块:成员连同各自前面紧挨着的注释块,照源码顺序。成员并进来了,讲它的注释跟着并,不在卡片下面单独留一块;
* 讲别的子块(方法)的注释、最后一个子块后面的注释照常留下。
*/
export function withLeadingComments(children: readonly CodeBlock[], members: readonly CodeBlock[]): CodeBlock[] {
return children.filter(child => members.includes(child))
.flatMap(member => [...leadingComments(children, member), member])
}
/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);当注释用的字符串是那个字符串。 */
function commentNodes(block: CodeBlock): Node[] {
if (block.node.type !== 'comment')
@@ -56,15 +38,15 @@ function commentNodes(block: CodeBlock): Node[] {
}
/**
* Python 类主体里一个成员(赋值、只写注解的字段)的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、
* 紧跟在它后面的属性 docstring。
* Python 类主体里一个成员(赋值、只写注解的字段)的说明,照源码顺序:讲它的注释(BlockShape.comments,它上面的、
* 类主体最后一个成员后面的)、它这一行行尾的注释、紧跟在它后面的属性 docstring。
*/
export function memberDocs(children: readonly CodeBlock[], block: CodeBlock): Node[] {
export function memberDocs(block: CodeBlock): Node[] {
const trailing = block.node.nextSibling
const doc = block.kind === 'variable' ? block.facts.doc : block.kind === 'field' ? block.doc : undefined
return [
...leadingComments(children, block).flatMap(commentNodes),
...(block.comments ?? []).flatMap(commentNodes),
...(trailing?.type === 'comment' && trailing.startPosition.row === block.node.endPosition.row ? [trailing] : []),
...(doc ? [doc] : []),
]
].sort((a, b) => a.startIndex - b.startIndex)
}
@@ -91,7 +91,7 @@ const RULE_CASES = [
describe('dataclasses 库规则(Python)', () => {
it('luffy 的数据类:「数据类」卡片,选项写成大白话,字段表是 字段 | 类型 | 默认值 | 说明,方法照常是子块', async () => {
const list = await views(SNAPSHOT)
expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', 'import', ...Array.from({ length: 4 }, () => 'dataclasses.dataclass')])
expect(list.map(view => view.kind)).toEqual(['import', 'import', 'import', ...Array.from({ length: 4 }, () => 'dataclasses.dataclass')])
expect(propertyLines(properties(named(list, 'AdminUserRow')))).toEqual([
'名称: AdminUserRow',
'说明: admin 用户列表单行: 用户 + 归属渠道 + 是否曾被邀请。',
+3 -3
View File
@@ -1,7 +1,7 @@
import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
import { annotatedAssignments, memberDocs } from './children'
import { docBodyRows, paramDocs, throwsRows, unmatchedParamRows } from './docRows'
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -217,7 +217,7 @@ function defaultCell(field: Field, call: Call | undefined, env: Env): Inline[] {
*/
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, initOnly: boolean, docs: ParamDocs, env: Env): PreviewText {
const explicit = stringValue(call?.keywords.get('doc'))
const text = explicit ?? memberDocs(children, field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
const text = explicit ?? memberDocs(field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
const written = text ? undefined : docs.of(field.name.text)
if (written)
return initOnly ? { markdown: `${env.words.initOnly} · ${written.markdown}` } : written
@@ -280,7 +280,7 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine
...(concepts.length > 0 && { concepts }),
rows,
...(facts.name && { defines: range(facts.name) }),
absorbs: withLeadingComments(children, [...fields.map(field => field.block), ...markers]),
absorbs: [...fields.map(field => field.block), ...markers],
}
}
+4 -4
View File
@@ -6,7 +6,7 @@ import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { commentText, flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { celeryRule } from './celery'
@@ -96,7 +96,7 @@ const RULE_CASES = [
describe('enum 库规则(Python)', () => {
it('luffy 的枚举:和原型一样的枚举卡片,说明取行尾注释,类的 docstring 当说明', async () => {
const list = await views(SNAPSHOT)
expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', 'import', 'enum', 'enum', 'enum', 'enum', 'enum'])
expect(list.map(view => view.kind)).toEqual(['import', 'import', 'import', 'enum', 'enum', 'enum', 'enum', 'enum'])
const commission = named(list, 'CommissionStatus')
expect(commission.badge).toBeUndefined()
expect(propertyLines(properties(commission))).toEqual([
@@ -162,7 +162,7 @@ describe('enum 库规则(Python)', () => {
])
})
it('方法、嵌套类、不是成员的属性不管写在哪都照常是子块;成员前面的注释并进卡片,最后面的注释留下;卡片「编辑」写回整个类', async () => {
it('方法、嵌套类、不是成员的属性不管写在哪都照常是子块;成员前面的注释并进卡片,最后面的注释挂在最后一块上;卡片「编辑」写回整个类', async () => {
const list = await views(RULE_CASES)
const color = named(list, 'Color')
const lines = RULE_CASES.split('\n')
@@ -178,8 +178,8 @@ describe('enum 库规则(Python)', () => {
'def describe(self) -> str:',
'_value_: int',
'class Meta:',
'# 收尾',
])
expect(commentText(color.children.at(-1))).toBe('收尾')
expect([lines[color.startLine], lines[color.endLine]]).toEqual(['class Color(E):', ' # 收尾'])
expect(replaceLines(RULE_CASES, color.startLine, color.endLine, color.code)).toBe(RULE_CASES)
const edited = replaceLines(RULE_CASES, color.startLine, color.endLine, color.code.replace('GREEN = 2', 'GREEN = 20'))
+3 -3
View File
@@ -1,6 +1,6 @@
import type { ClassBlock, CodeBlock, EnumMemberFacts, LibraryRule, Node, RuleFacts } from '../contract'
import type { LibraryNames } from './pythonTree'
import { memberDocs, withLeadingComments } from './children'
import { memberDocs } from './children'
import { libraryName, libraryNames, stringValue, syntaxChildren } from './pythonTree'
/**
@@ -86,12 +86,12 @@ function enumBlock(block: ClassBlock, names: LibraryNames): RuleFacts | undefine
const member = memberOf(child, ignored, names)
if (!member)
continue
members.push({ ...member, docs: memberDocs(children, child) })
members.push({ ...member, docs: memberDocs(child) })
blocks.push(child)
}
return {
as: { kind: 'enum', facts: { name: facts.name, doc: facts.doc, access: facts.access, decorators: facts.decorators, bases: facts.bases, members } },
absorbs: withLeadingComments(children, blocks),
absorbs: blocks,
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import type { Access, ClassBlock, CodeBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
import { annotatedAssignments, memberDocs } from './children'
import { docBodyRows, paramDocs, throwsRows, unmatchedParamRows } from './docRows'
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -338,7 +338,7 @@ function docCell(field: Field, children: readonly CodeBlock[], call: Call | unde
return [{ text: literal, role: 'comment' }]
if (description)
return env.context.translate(description)
const text = memberDocs(children, field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
const text = memberDocs(field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : docs.of(field.name.text) ?? []
}
@@ -386,7 +386,7 @@ function modelCard(block: ClassBlock, env: Env): RuleCard {
badge: { label: words.kind, color: COLOR, icon: ICON },
rows,
...(facts.name && { defines: range(facts.name) }),
absorbs: withLeadingComments(children, [...fields.map(field => field.block), ...(config ? [config.block] : [])]),
absorbs: [...fields.map(field => field.block), ...(config ? [config.block] : [])],
}
}
+12 -16
View File
@@ -55,7 +55,7 @@ async function card(source: string, name: string, lookup?: RuleContext['lookup']
/** 卡片下面从第 line 行(1 起)开始的那一块子块。 */
function childAt(view: BlockView, line: number): BlockView {
const found = view.children.find(child => child.startLine + 1 === line && child.kind !== 'comment')
const found = view.children.find(child => child.startLine + 1 === line)
if (!found)
throw new Error(`第 ${line} 行没有子块`)
return found
@@ -265,8 +265,7 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
'variable 59',
'variable 60',
'variable 61',
'comment 62',
'variable 63',
'variable 62',
'react.render 65',
])
})
@@ -295,7 +294,7 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
' if 39: 如果 (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) 全等 "1")',
' statement 40: queueMicrotask(() => setCollapsed(真));',
' catch 42: 捕获',
' comment 43: localStorage 不可用 (隐私模式等) 时静默用默认',
' 注释: localStorage 不可用 (隐私模式等) 时静默用默认',
])
const toggle = childAt(view, 47)
expect(toggle.badge).toEqual({ label: '派生值', color: DATA, icon: 'sigma' })
@@ -311,7 +310,7 @@ describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklis
' try 50: 尝试',
' statement 51: localStorage.setItem(PLAN_CHECKLIST_COLLAPSED_KEY, 如果 next 则 "1" 否则 "0");',
' catch 52: 捕获',
' comment 53: 忽略写入失败',
' 注释: 忽略写入失败',
])
expect(PLAN.slice(toggle.defines?.startIndex, toggle.defines?.endIndex)).toBe('toggle')
})
@@ -369,8 +368,8 @@ describe('react 库规则:luffy 的 QueuedBubble(原型「编辑器全貌」
' if 187: 如果 (e.nativeEvent.isComposing)',
' return 187: 返回;',
' if 188: 如果 (e.key 全等 "Escape")',
' comment 189: 修改框里的 Esc 只用来取消修改, 不再往外传: 页面上「连按两次 Esc 停止这一轮」不该把它算进去',
' statement 190: e.preventDefault();',
' statement 189: e.preventDefault();',
' 注释: 修改框里的 Esc 只用来取消修改, 不再往外传: 页面上「连按两次 Esc 停止这一轮」不该把它算进去',
' statement 191: e.stopPropagation();',
' statement 192: onCancel?.();',
' else 193: 否则 如果 (e.key 全等 "Enter" 并且 (e.metaKey 或者 e.ctrlKey))',
@@ -427,7 +426,7 @@ describe('react 库规则:数据源、派生值、副作用、事件处理函
expect(rowLines(childAt(await card(SETTINGS, 'WorkspaceRootRow'), 90), '说明')).toEqual(['说明: 基于 desktop store 计算 displayPath(s.workspace_root, s.home)'])
})
it('luffy 的 LegalDocPage:useMemo、渲染期间按条件重置写进更新位置、引用的使用位置、清理函数并进副作用、处理函数绑在每个按钮上,注释照常成块', async () => {
it('luffy 的 LegalDocPage:useMemo、渲染期间按条件重置写进更新位置、引用的使用位置、清理函数并进副作用、处理函数绑在每个按钮上,注释是讲的那张卡片的「注释」一行', async () => {
const view = await card(LEGAL, 'LegalDocPage')
expect(kinds(view)).toEqual([
'react.source 58',
@@ -436,13 +435,10 @@ describe('react 库规则:数据源、派生值、副作用、事件处理函
'react.derived 61',
'react.ref 63',
'react.state 64',
'comment 68',
'react.state 69',
'react.state 68',
'if 70',
'comment 75',
'react.effect 76',
'comment 80',
'react.effect 81',
'react.effect 75',
'react.effect 80',
'react.handler 104',
'react.handler 112',
'react.handler 116',
@@ -451,8 +447,8 @@ describe('react 库规则:数据源、派生值、副作用、事件处理函
expect(lines(childAt(view, 61))).toEqual(['名称: headings', '计算表达式: extractHeadings(markdown)', '依赖项: markdown(useMemo)'])
expect(lines(childAt(view, 63))).toEqual(['名称: scrollRef', '初始值: 空', '使用位置: <main> 的 ref 属性;副作用 1、副作用 2、handleSectionClick'])
expect(lines(childAt(view, 64))).toEqual(['名称: activeSection', '初始值: headings[0]?.id ?? ""', '更新位置: 渲染期间,当 doc 不全等 prevDoc 时、副作用 2'])
const effect = childAt(view, 81)
expect(lines(effect)).toEqual(['触发时机: doc、headings 变化时', '清理函数: container.removeEventListener("scroll", handleScroll)'])
const effect = childAt(view, 80)
expect(lines(effect)).toEqual(['触发时机: doc、headings 变化时', '注释: 滚动时按 heading.top 计算当前高亮章节.', '清理函数: container.removeEventListener("scroll", handleScroll)'])
expect(kinds(effect)).toEqual(['variable 82', 'if 83', 'function 84', 'statement 100'])
expect(lines(childAt(view, 104))).toEqual(['名称: handleSectionClick', '参数: id: 字符串', '绑定事件: 每个 <button> 的 onClick(传入 h.id)'])
expect(kinds(childAt(view, 104))).toEqual(['variable 105', 'variable 106', 'if 107'])
+4 -11
View File
@@ -2,7 +2,7 @@ import type { CodeBlock, Doc, Inline, KindIconName, LibraryRule, Markdown, Node,
import type { ParamDocs } from './docRows'
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts, RefFacts, StateFacts } from './reactTree'
import { docBodyRows, paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentText, componentAt, componentOf, componentOfFunction, componentsIn, declaredTypes, enclosingComponent, enclosingFunction, isComponentTag, isFunction, isJsx, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentText, componentOf, componentOfFunction, componentsIn, declaredTypes, enclosingComponent, enclosingFunction, isComponentTag, isFunction, isJsx, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
/**
@@ -1044,19 +1044,12 @@ function bodyCard(block: CodeBlock, env: Env): RuleCard | undefined {
}
/**
* 讲在了组件卡片里、不再单独成块的:文件开头的 "use client"(文件里有组件时,讲成「客户端组件」标签),
* 紧贴在组件前面、没并进组件块头部的 JSDoc(值是 memo(…)、直接返回 JSX 的箭头函数这类常量块,讲成「说明」)。
* 讲在了组件卡片里、不再单独成块的:文件开头的 "use client"(文件里有组件时,讲成「客户端组件」标签)。
* 挂着注释的不略去,注释照常在它的「注释」一行里。
*/
function omitted(block: CodeBlock): boolean {
const root = block.node.tree.rootNode
if (block.kind === 'statement')
return sameNode(clientDirective(root), block.node) && componentsIn(root).length > 0
if (block.kind === 'comment') {
const next = block.node.nextSibling
const component = next ? componentAt(next) : undefined
return !!component?.doc && sameNode(component.doc, block.node)
}
return false
return block.kind === 'statement' && !block.comments && sameNode(clientDirective(root), block.node) && componentsIn(root).length > 0
}
export const reactRule: LibraryRule = {
+2 -2
View File
@@ -75,9 +75,9 @@ describe('serde 与 thiserror', () => {
' 来源: sqlx',
' 导入项: 1 个',
' query_as | —',
'sqlx.query 40',
' 说明: 按 id 取一行。',
'sqlx.query 39',
' SQL: SELECT id, name FROM agents WHERE id = $1',
' 注释: 按 id 取一行。',
' 参数: $1 = id',
' 返回类型: AgentRow',
'import 42',
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { commentText, flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { sqlalchemyRule } from './sqlalchemy'
@@ -108,7 +108,6 @@ describe('sqlAlchemy 库规则', () => {
it('只认模型类:枚举类、函数照常显示;模型类的赋值都并进卡片,不再单独成块', async () => {
const list = await views(SNAPSHOT)
expect(list.map(view => view.kind)).toEqual([
'comment',
...Array.from({ length: 9 }, () => 'import'),
'function',
'class',
@@ -159,7 +158,8 @@ describe('sqlAlchemy 库规则', () => {
].join('\n')
const [, order] = await views(source)
expect(propertyLines(properties(order)).slice(-3)).toEqual(['字段: 2 个', ' amount | 整数 | | ', ' note | 长文本 | | '])
expect(order.children.map(child => [child.kind, child.startLine])).toEqual([['comment', 9], ['function', 10]])
expect(order.children.map(child => [child.kind, child.startLine])).toEqual([['function', 9]])
expect(commentText(order.children[0])).toBe('算税')
expect([order.startLine, order.endLine]).toEqual([3, 13])
expect(replaceLines(source, order.startLine, order.endLine, order.code)).toBe(source)
expect(replaceLines(source, order.startLine, order.endLine, order.code.replace('// 10', '// 8'))).toBe(source.replace('// 10', '// 8'))
+1 -2
View File
@@ -1,6 +1,5 @@
import type { ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, TokenRole } from '../contract'
import type { Call } from './pythonTree'
import { withLeadingComments } from './children'
import { aliasesOf, callOf, canonicalName, genericOf, lastName, stringValue, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -439,7 +438,7 @@ function tableBlock(block: ClassBlock, model: Model, env: Env): RuleCard {
badge: { label: words.kind, color: TABLE_COLOR, icon: TABLE_ICON },
rows,
...(facts.name && { defines: range(facts.name) }),
absorbs: withLeadingComments(block.children, members.filter((member): member is Member => member !== undefined).map(member => member.block)),
absorbs: members.filter((member): member is Member => member !== undefined).map(member => member.block),
}
}
+6 -26
View File
@@ -1,5 +1,5 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import { docSummary, macroInvocation, named, oneLine, stringContent } from './rustTree'
import type { Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, ThemeColor } from '../contract'
import { macroInvocation, named, oneLine, stringContent } from './rustTree'
/** 查询卡用字符串那种橙。 */
const COLOR: ThemeColor = { dark: '#ce9178', light: '#a31515' }
@@ -7,7 +7,7 @@ const BADGE: KindBadge = { label: '查询', color: COLOR, icon: 'database' }
const MACROS = new Set(['query', 'query_as', 'query_scalar'])
/**
* sqlx::query! / query_as! 这类宏。一句说明、等宽 SQL、参数、返回类型。
* sqlx::query! / query_as! 这类宏。等宽 SQL、参数、返回类型;前面的注释同别的块,是卡片的「注释」一行。
* 文件里导入了 sqlx 才启用。
*/
export const sqlxRule: LibraryRule = {
@@ -15,16 +15,14 @@ export const sqlxRule: LibraryRule = {
label: 'sqlx 查询',
languageIds: ['rust'],
libraries: ['sqlx'],
claim: (block, context) => {
if (block.kind === 'comment')
return nextQuery(block.node) ? { omit: true } : undefined
claim: (block) => {
const macro = block.kind === 'statement' ? findQuery(block.node) : undefined
return macro ? card(block, macro, context) : undefined
return macro ? card(macro) : undefined
},
attributeNames: () => ({ attributes: {}, values: { kind: { 'sqlx.query': '查询' } } }),
}
function card(block: CodeBlock, macro: Node, context: RuleContext): RuleCard {
function card(macro: Node): RuleCard {
const call = macroInvocation(macro)
const args = call?.args ?? []
const typed = call?.name.split('::').pop() !== 'query'
@@ -32,9 +30,7 @@ function card(block: CodeBlock, macro: Node, context: RuleContext): RuleCard {
const sql = stringContent(sqlNode) ?? ''
const params = args.slice(typed ? 2 : 1)
const returned = typed ? args[0] : undefined
const note = precedingNote(block.node, context)
const rows: PropertyRow[] = [
...(note ? [{ label: '说明', value: { markdown: note } }] : []),
{ label: 'SQL', value: [{ text: sql, role: 'string', ...(sqlNode && { offset: sqlNode.startIndex }) }] },
...(params.length > 0
? [{ label: '参数', value: [{ text: params.map((param, index) => `$${index + 1} = ${oneLine(param.text)}`).join('、'), role: 'plain' as const }] }]
@@ -58,22 +54,6 @@ function findQuery(node: Node): Node | undefined {
return undefined
}
function nextQuery(comment: Node): Node | undefined {
let next = comment.nextNamedSibling
while (next?.type === 'attribute_item')
next = next.nextNamedSibling
return next ? findQuery(next) : undefined
}
function precedingNote(node: Node, context: RuleContext): string | undefined {
let prev = node.previousNamedSibling
while (prev?.type === 'attribute_item')
prev = prev.previousNamedSibling
if (!prev || (prev.type !== 'line_comment' && prev.type !== 'block_comment'))
return undefined
return docSummary(context.doc(prev))
}
function hint(text: string): Inline {
return { text, role: 'hint' }
}
+23 -7
View File
@@ -30,14 +30,18 @@ export async function withBlocks<T>(adapter: LanguageAdapter, languageId: string
}
}
/** 和原型 CODE_TREE 同样的写法:at 是 1 起的行号(单行,或 [起, 止])。 */
export interface Outline { at: number | [number, number], kind: BlockKind, kids?: Outline[] }
/** 和原型 CODE_TREE 同样的写法:at 是 1 起的行号(单行,或 [起, 止]);notes 是挂在这块上的各段注释(BlockShape.comments)在哪。 */
export interface Outline { at: number | [number, number], kind: BlockKind, notes?: Outline['at'][], kids?: Outline[] }
function rowsAt({ startRow, endRow }: { startRow: number, endRow: number }): Outline['at'] {
return startRow === endRow ? startRow + 1 : [startRow + 1, endRow + 1]
}
export function outline(blocks: CodeBlock[]): Outline[] {
return blocks.map((block) => {
const from = block.header.startRow + 1
const to = block.header.endRow + 1
const item: Outline = { at: from === to ? from : [from, to], kind: block.kind }
const item: Outline = { at: rowsAt(block.header), kind: block.kind }
if (block.comments)
item.notes = block.comments.map(comment => rowsAt(comment.header))
if (block.children.length > 0)
item.kids = outline(block.children)
return item
@@ -91,7 +95,7 @@ export function tableLines(table: PreviewTable | undefined): string[] {
}
/**
* 视图数据写成文字(整份源码读出来的样子):一行译文的块是「种类 行号: 译文」,属性表块是「种类 行号」下面每行「属性: 值」,
* 视图数据写成文字(整份源码读出来的样子):一行译文的块是「种类 行号: 译文」(下面的「注释」一行写成「注释: 第一行」),属性表块是「种类 行号」下面每行「属性: 值」,
* 子块比上一层多缩进两格;Markdown 只取第一行。表(属性行展开的、译文下面的、格子里套的)每行比上一层多缩进,
* 格子用 | 隔开,格子里的换行写成 ⏎。
*/
@@ -105,11 +109,23 @@ export function viewLines(blocks: readonly BlockView[], indent = ''): string[] {
return blocks.flatMap((block) => {
const head = 'properties' in block
? [`${indent}${block.kind} ${block.startLine + 1}`, ...block.properties.flatMap(row => [`${indent} ${row.label}: ${value(row.value)}`, ...items(row.items, `${indent} `)])]
: [`${indent}${block.kind} ${block.startLine + 1}: ${value(block.translation)}`, ...items(block.table?.items, `${indent} `)]
: [
`${indent}${block.kind} ${block.startLine + 1}: ${value(block.translation)}`,
...items(block.table?.items, `${indent} `),
...(block.rows ?? []).map(row => `${indent} ${row.label}: ${value(row.value)}`),
]
return [...head, ...viewLines(block.children, `${indent} `)]
})
}
/** 卡片上「注释」一行的 Markdown 原文(BlockView.commentRow);没有就是 undefined。 */
export function commentText(view: BlockView | undefined): string | undefined {
if (!view || view.commentRow === undefined)
return undefined
const row = 'properties' in view ? view.properties[view.commentRow] : view.rows?.[view.commentRow]
return row && 'markdown' in row.value ? row.value.markdown : undefined
}
/** 语法树节点换成它的源码,没填的可选字段去掉。 */
export function nodesToText(value: unknown): unknown {
if (value instanceof Node)
@@ -27,9 +27,9 @@ const FETCH_READING = [
'interpreter 1',
' 解释器: bash',
' 怎么找: 按 PATH 去找(/usr/bin/env bash)',
'comment 2: 下载执行器自带的 git 与 python, 放到 src-tauri/resources/runtime/<target>/{git,python}。',
'options 14',
'options 2',
' 出错就停: 是 -e',
' 注释: 下载执行器自带的 git 与 python, 放到 src-tauri/resources/runtime/<target>/{git,python}。',
' 用到没设的变量就报错: 是 -u',
' 管道里任一步失败都算失败: 是 -o pipefail',
'variable 16',
@@ -101,41 +101,41 @@ const FETCH_READING = [
'case 68: 按 git_pkg 分情况',
' 以 .zip 结尾 | 解压 zip git_pkg 到 dest/git(不打印过程)',
' 其他 | 解压 tar.gz git_pkg 到 dest/git',
'comment 72: python-build-standalone 的包里顶层目录就叫 python',
'command 73: 解压 tar.gz py_pkg 到 dest',
'comment 75: ── 去掉 Git Credential Manager ──────────────────────────────────────────────',
'case 78: 按 target 分情况',
'command 72: 解压 tar.gz py_pkg 到 dest',
' 注释: python-build-standalone 的包里顶层目录就叫 python',
'case 75: 按 target 分情况',
' 注释: ── 去掉 Git Credential Manager ──────────────────────────────────────────────',
' case 79: 情况:以 -apple-darwin 结尾',
' variable 80',
' 名称: gc',
' 初始值: dest/git/libexec/git-core',
' command 81: 删除 gc/git-credential-manager*、gc/*.dll、gc/*.dylib、gc/createdump、gc/NOTICE、gc/uninstall.sh(连同里面的东西、不提示)',
' comment 83: .NET 的各语言资源目录; git 自己的子目录只有 mergetools',
' command 84: 找出 gc 下一层的目录,除了 mergetools,逐个 删除(连同里面的东西、不提示)',
' command 83: 找出 gc 下一层的目录,除了 mergetools,逐个 删除(连同里面的东西、不提示)',
' 注释: .NET 的各语言资源目录; git 自己的子目录只有 mergetools',
' case 86: 情况:以 -windows-msvc 结尾',
' comment 87: mingw64/bin 里 git 自己依赖的 DLL 都以 lib 开头 (外加 zlib1.dll), 其余 DLL 都属于 GCM;',
' variable 89',
' variable 87',
' 名称: mb',
' 注释: mingw64/bin 里 git 自己依赖的 DLL 都以 lib 开头 (外加 zlib1.dll), 其余 DLL 都属于 GCM;',
' 初始值: dest/git/mingw64/bin',
' command 90: 删除 mb/git-credential-manager.exe*、mb/libHarfBuzzSharp.dll、mb/libSkiaSharp.dll(不提示)',
' command 91: 找出 mb 这一层的 *.dll,除了 lib* 和 zlib1.dll,逐个 删除',
'comment 95: Windows 版 python 只有 python.exe; 平台脚本按 python3 这个名字再调起它时 (它的目录前置在',
'if 98: 如果 target 以 -windows-msvc 结尾',
'if 95: 如果 target 以 -windows-msvc 结尾',
' 注释: Windows 版 python 只有 python.exe; 平台脚本按 python3 这个名字再调起它时 (它的目录前置在',
' command 99: 复制 dest/python/python.exe 到 dest/python/python3.exe',
'comment 102: ── 去掉符号链接 ─────────────────────────────────────────────────────────────',
'if 105: 如果 target 以 -apple-darwin 结尾',
'if 102: 如果 target 以 -apple-darwin 结尾',
' 注释: ── 去掉符号链接 ─────────────────────────────────────────────────────────────',
' variable 106',
' 名称: gc',
' 初始值: dest/git/libexec/git-core',
' comment 107: 内建命令的短横线别名 (git-add -> git …): git 自己调内建命令不走它们。同 git 构建选项',
' loop 109: 遍历 gc/git-* 匹配到的每个文件 f',
' loop 107: 遍历 gc/git-* 匹配到的每个文件 f',
' 注释: 内建命令的短横线别名 (git-add -> git …): git 自己调内建命令不走它们。同 git 构建选项',
' if 110: 如果不是 〔f 是符号链接 并且 〔读出 f 指向的路径〕 等于 git〕,跳过这一个',
' case 111: 按 〔取文件名 f〕 分情况',
' git-upload-pack 或 git-receive-pack 或 git-upload-archive | 什么也不做',
' 其他 | 删除 f',
' comment 116: python 只留 bin/python3 这一个入口 (执行器按它找), 版本号命名的本体挪过来',
' variable 117',
' variable 116',
' 名称: pybin',
' 注释: python 只留 bin/python3 这一个入口 (执行器按它找), 版本号命名的本体挪过来',
' 初始值: dest/python/bin',
' variable 118',
' 名称: real',
@@ -143,8 +143,8 @@ const FETCH_READING = [
' command 119: 删除 pybin/python3',
' command 120: 改名 pybin/real → pybin/python3',
' command 121: 找出 dest/python 下面所有符号链接,逐个 删除',
' comment 122: 剩下的 (传输程序、git-remote-https 之类) 换成实体文件',
' pipeline 123: 找出 dest 下面所有符号链接 → 逐行读进 link',
' pipeline 122: 找出 dest 下面所有符号链接 → 逐行读进 link',
' 注释: 剩下的 (传输程序、git-remote-https 之类) 换成实体文件',
' command 124: 复制 link 指向的实体文件 到 link.tmp',
' command 125: 改名 link.tmp → link',
'command 129: 打印 "自带运行时就绪: dest"(打印到错误输出)',
@@ -54,9 +54,9 @@ const NEXT_READING = [
' variable 14',
' 环境变量: NEXT_TELEMETRY_DISABLED',
' 值: 1',
' comment 15: 部署 ID 烙进本次构建: Next 据此给静态资源加 ?dpl=、页内跳转时比对版本;',
' variable 17',
' variable 15',
' 构建参数: DEPLOYMENT_ID',
' 注释: 部署 ID 烙进本次构建: Next 据此给静态资源加 ?dpl=、页内跳转时比对版本;',
' 默认值: 没写,构建时传',
' variable 18',
' 环境变量: NEXT_DEPLOYMENT_ID',
@@ -84,8 +84,8 @@ const NEXT_READING = [
' copy 33: 复制(2 条连着写,属主都是 nextjs:nodejs)',
' 阶段 builder | /app/.next/standalone | ./',
' 阶段 builder | /app/.next/static | ./.next/static',
' comment 35: standalone 输出不含 public/, 必须显式拷贝 (字体等静态资源)',
' copy 36: 从阶段 builder 复制 /app/public → ./public(属主 nextjs:nodejs)',
' copy 35: 从阶段 builder 复制 /app/public → ./public(属主 nextjs:nodejs)',
' 注释: standalone 输出不含 public/, 必须显式拷贝 (字体等静态资源)',
' setting 38: 之后以 nextjs 身份运行',
' setting 39: 开放端口 3000',
' start 41',
@@ -102,8 +102,8 @@ const BACKEND_READING = [
' label 6: 标签',
' maintainer | Cruldra',
' description | luffy-agent-platform - 统一 Backend 镜像 (API/Worker/Beat/Flower)',
' comment 9: git: deployment 模块创建 bare repo;postgresql-client: alembic / psql 运维;curl: healthcheck',
' run 13: 执行(10 条命令,一条失败就停)',
' run 9: 执行(10 条命令,一条失败就停)',
' 注释: git: deployment 模块创建 bare repo;postgresql-client: alembic / psql 运维;curl: healthcheck',
' command 13: 更新软件包列表',
' command 14: 安装 ca-certificates、curl、gnupg、postgresql-client、git(不装推荐的包、不用确认)',
' command 16: 建目录 /usr/share/keyrings',
@@ -114,16 +114,16 @@ const BACKEND_READING = [
' command 23: 安装 postgresql-16-repack(不装推荐的包、不用确认)',
' command 24: 打印 pg_repack 的版本',
' command 25: 删除 /var/lib/apt/lists/*(连同里面的东西、不提示)',
' comment 27: Node.js:agent MCP 节点 (如 context7) 用 stdio + npx 拉子进程, api 容器必须有 npx。',
' copy 32: 复制(2 条连着写)',
' copy 27: 复制(2 条连着写)',
' 镜像 node:22-slim | /usr/local/bin/node | /usr/local/bin/node',
' 镜像 node:22-slim | /usr/local/lib/node_modules | /usr/local/lib/node_modules',
' 注释: Node.js:agent MCP 节点 (如 context7) 用 stdio + npx 拉子进程, api 容器必须有 npx。',
' run 34: 执行',
' command 34: 建符号链接 /usr/local/bin/npm → /usr/local/lib/node_modules/npm/bin/npm-cli.js(已有就替换)',
' command 35: 建符号链接 /usr/local/bin/npx → /usr/local/lib/node_modules/npm/bin/npx-cli.js(已有就替换)',
' copy 37: 从镜像 ghcr.io/astral-sh/uv:latest 复制 /uv、/uvx → /bin/',
' comment 39: appuser 先建、/app /lib 先归属:.venv 由 appuser 直接创建,末尾无需 chown -R',
' run 42: 执行(3 条命令,一条失败就停)',
' run 39: 执行(3 条命令,一条失败就停)',
' 注释: appuser 先建、/app /lib 先归属:.venv 由 appuser 直接创建,末尾无需 chown -R',
' command 42: 新建用户 appuser(建主目录、不能登录)',
' command 43: 建目录 /app、/lib(连同上级目录)',
' command 44: 把 /app、/lib 的属主改成 appuser:appuser',
@@ -132,8 +132,7 @@ const BACKEND_READING = [
' 环境变量: UV_CACHE_DIR',
' 值: /home/appuser/.cache/uv',
' setting 48: 工作目录 /app',
' comment 50: ── 依赖层(仅随 pyproject/uv.lock 变化重建)───────────────────────────',
' copy 54: 复制(12 条连着写,属主都是 appuser:appuser)',
' copy 50: 复制(12 条连着写,属主都是 appuser:appuser)',
' backend/pyproject.toml、backend/uv.lock | ./',
' lib/agent-dsl/pyproject.toml | /lib/agent-dsl/',
' lib/agent-compiler-deepagents/pyproject.toml | /lib/agent-compiler-deepagents/',
@@ -146,39 +145,40 @@ const BACKEND_READING = [
' lib/code-publish-compose-runner/pyproject.toml | /lib/code-publish-compose-runner/',
' lib/luffy-billing-sdk/pyproject.toml | /lib/luffy-billing-sdk/',
' lib/luffy-observability/pyproject.toml | /lib/luffy-observability/',
' comment 67: 三方依赖先装;本项目与 lib editable 包此时无源码,跳过安装留给第二次 sync',
' run 68: uv 按 lock 同步依赖 · 4 个选项',
' 注释: ── 依赖层(仅随 pyproject/uv.lock 变化重建)───────────────────────────',
' run 67: uv 按 lock 同步依赖 · 4 个选项',
' --frozen | 严格按 lock 文件,不改它',
' --no-dev | 不装开发依赖',
' --no-install-project | 不装本项目',
' --no-install-package × 11 | 不装这些包:agent-dsl、agent-compiler-deepagents、vendor-tools、code-sandbox-core、code-sandbox-daytona、code-sandbox-e2b、code-sandbox-local、code-publish-core、code-publish-compose-runner、luffy-billing-sdk、luffy-observability',
' comment 81: ── 源码层(每次代码变更只重建这里往下)────────────────────────────────',
' copy 82: 复制 lib → /lib(属主 appuser:appuser)',
' comment 83: agent 定义 (vendor/agents/*/agent.yaml + nodes/*.py) 与代码是同一个版本, 必须随镜像原子切换;',
' copy 85: 复制(5 条连着写,属主都是 appuser:appuser)',
' 注释: 三方依赖先装;本项目与 lib editable 包此时无源码,跳过安装留给第二次 sync',
' copy 81: 复制 lib → /lib(属主 appuser:appuser)',
' 注释: ── 源码层(每次代码变更只重建这里往下)────────────────────────────────',
' copy 83: 复制(5 条连着写,属主都是 appuser:appuser)',
' vendor | /vendor',
' backend/src/ | ./src/',
' backend/alembic/ | ./alembic/',
' backend/alembic.ini | ./',
' backend/scripts/ | ./scripts/',
' comment 91: 补装本项目 + lib editable 包(三方依赖已就位,秒级)',
' run 92: uv 按 lock 同步依赖(严格按 lock 文件,不改它、不装开发依赖)',
' comment 94: 远程调试支持:装入 uv 创建的 venv,按需通过 DEBUGPY_ENABLE=1 开启。',
' run 96: uv 安装 debugpy',
' 注释: agent 定义 (vendor/agents/*/agent.yaml + nodes/*.py) 与代码是同一个版本, 必须随镜像原子切换;',
' run 91: uv 按 lock 同步依赖(严格按 lock 文件,不改它、不装开发依赖)',
' 注释: 补装本项目 + lib editable 包(三方依赖已就位,秒级)',
' run 94: uv 安装 debugpy',
' 注释: 远程调试支持:装入 uv 创建的 venv,按需通过 DEBUGPY_ENABLE=1 开启。',
' variable 98',
' 环境变量: UV_NO_SYNC',
' 值: 1',
' comment 100: 部署 ID (Jenkins IMAGE_TAG): /health 回显、日志定位后端出自哪次构建. 选择性发布时 api 与 client 镜像 tag 可不同, 客户端是否过期以 client 镜像 (浏览器包 vs BFF) 为准.',
' variable 101',
' variable 100',
' 构建参数: DEPLOYMENT_ID',
' 注释: 部署 ID (Jenkins IMAGE_TAG): /health 回显、日志定位后端出自哪次构建. 选择性发布时 api 与 client 镜像 tag 可不同, 客户端是否过期以 client 镜像 (浏览器包 vs BFF) 为准.',
' 默认值: 没写,构建时传',
' variable 102',
' 环境变量: DEPLOYMENT_ID',
' 值: DEPLOYMENT_ID(构建参数)',
' setting 104: 开放端口 8000',
' comment 106: 默认 CMD 走 api,celery worker/beat/flower 由 compose 覆盖 command',
' health 107',
' health 106',
' 间隔: 30 秒',
' 注释: 默认 CMD 走 api,celery worker/beat/flower 由 compose 覆盖 command',
' 超时: 5 秒',
' 启动后宽限: 10 秒',
' 失败几次算不健康: 3 次',
@@ -15,21 +15,22 @@ const py = createPythonAdapter(WASM_DIR)
interface Translated { kind: string, segments: Inline[], markdown?: string, table?: PreviewTable }
/**
* 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。
* 一行译文块、注释块(单独成块的、挂在块上的)的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。
* calleeKind 同 SourceDocument.calleeKind(调用的名字定义成哪种块),不给就是宿主不查。
*/
function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, calleeKind?: SourceDocument['calleeKind']): Promise<Map<number, Translated>> {
return withBlocks(adapter, languageId, source, (blocks) => {
const byLine = new Map<number, Translated>()
walk(blocks, (block) => {
const line = block.header.startRow + 1
if (byLine.has(line) || 'facts' in block)
// 挂在块上的注释(BlockShape.comments)同样按它自己的起始行翻
walk(blocks, block => [...(block.comments ?? []), block].forEach((item) => {
const line = item.header.startRow + 1
if (byLine.has(line) || 'facts' in item)
return
const { text: translation, table } = translator.translate(block, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) })
const { text: translation, table } = translator.translate(item, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) })
byLine.set(line, 'markdown' in translation
? { kind: block.kind, segments: [], markdown: translation.markdown }
: { kind: block.kind, segments: translation, ...(table && { table }) })
})
? { kind: item.kind, segments: [], markdown: translation.markdown }
: { kind: item.kind, segments: translation, ...(table && { table }) })
}))
return byLine
})
}