✨ 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:
1 parent
335cf9ca9f
commit
582ac4c987
49 files changed
+1730
-757
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, '', '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 那块下面的一次问答(已经理解了)
|
||||
|
||||
@@ -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()
|
||||
@@ -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] }])
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -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]] })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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] },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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('没给出结果')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
/** 问失败了,给人看的一句话。 */
|
||||
|
||||
@@ -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 用户列表单行: 用户 + 归属渠道 + 是否曾被邀请。',
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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] : [])],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user