- 普通注释不再单独成卡片:同一层挂到后面最近的块(隔空行也算),后面没有挂到前面那块,整层没代码挂到父块;只有文件开头隔着空行的注释、模块 docstring、//!、@file 单独成「文件开头的说明」 - 各语言(Python、TypeScript、Rust、Shell、Dockerfile)建好树后统一调 attachComments;块的编辑范围连着挂上的注释,差异、跳转、钉住卡片都按它 - 宿主统一给每张卡片(语言卡片、翻译插件的属性表、库规则卡片、一行译文的语句卡片)插「注释」一行,排在名称、说明后面 - TypeScript 的 JSDoc 也能当常量、变量的说明;sqlx、React 不再单独处理前面的注释 - 「重写注释」每块都有:提示只发 /ecc:writing-code-comments 路径:起-止,模型只读、按 --json-schema 交 body,没交结构化正文就失败不写;扩展换掉块前面的注释或在块上面新写一段
253 lines
13 KiB
TypeScript
253 lines
13 KiB
TypeScript
import type { Outline } from '../testSupport'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { factsByLine, nodeFacts, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
|
||
import { createBashAdapter } from './bash'
|
||
|
||
const adapter = createBashAdapter(WASM_DIR)
|
||
const FETCH = readFixture('fetchRuntime.sh')
|
||
|
||
function tree(source: string): Promise<Outline[]> {
|
||
return withBlocks(adapter, 'shellscript', source, outline)
|
||
}
|
||
|
||
function facts(source: string): Promise<Record<number, unknown>> {
|
||
return withBlocks(adapter, 'shellscript', source, factsByLine)
|
||
}
|
||
|
||
/** luffy 桌面端 fetch-runtime.sh 的代码块树(同原型 SHELL_BLOCKS,原型略去的 95-100、116-121 行也在)。 */
|
||
const FETCH_TREE: Outline[] = [
|
||
{ at: 1, kind: 'interpreter' },
|
||
{ at: 14, kind: 'options', notes: [[2, 13]] },
|
||
{ at: 16, kind: 'variable' },
|
||
{ at: 17, kind: 'variable' },
|
||
{ at: 18, kind: 'variable' },
|
||
{ at: 19, kind: 'variable' },
|
||
{ at: 21, kind: 'const' },
|
||
{ at: 22, kind: 'const' },
|
||
{ at: [24, 39], kind: 'case' },
|
||
{ at: 40, kind: 'variable' },
|
||
{ at: 42, kind: 'function', kids: [{ at: 43, kind: 'if' }] },
|
||
{ at: [46, 47], kind: 'function', kids: [
|
||
{ at: 48, kind: 'variable' },
|
||
{ at: 49, kind: 'variable' },
|
||
{ at: 50, kind: 'command' },
|
||
{ at: 51, kind: 'if', kids: [{ at: 52, kind: 'command' }, { at: 53, kind: 'command' }, { at: 54, kind: 'command' }] },
|
||
{ at: 56, kind: 'if', kids: [{ at: 57, kind: 'command' }, { at: 58, kind: 'command' }] },
|
||
{ at: 60, kind: 'command' },
|
||
] },
|
||
{ at: 63, kind: 'variable' },
|
||
{ at: 64, kind: 'variable' },
|
||
{ at: 66, kind: 'command' },
|
||
{ at: 67, kind: 'command' },
|
||
{ at: [68, 71], kind: 'case' },
|
||
{ 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: 98, kind: 'if', notes: [[95, 97]], kids: [{ at: 99, kind: 'command' }] },
|
||
{ at: 105, kind: 'if', notes: [[102, 104]], kids: [
|
||
{ at: 106, 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: 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 () => {
|
||
expect(await tree(FETCH)).toEqual(FETCH_TREE)
|
||
})
|
||
|
||
it('elif / else 展平成紧跟 if 的兄弟块;while、for 的主体是子块;一串全是条件、条件后面跟一句的算条件,别的一串命令算命令', async () => {
|
||
const source = [
|
||
'if [ -z "$a" ]; then',
|
||
' echo a',
|
||
'elif [ -n "$b" ]; then',
|
||
' echo b',
|
||
'else',
|
||
' echo c',
|
||
'fi',
|
||
'while true; do',
|
||
' sleep 1',
|
||
'done',
|
||
'[ -d "$x" ]',
|
||
'[ -f "$x" ] && rm "$x"',
|
||
'mkdir -p "$x" && cd "$x"',
|
||
'return 1',
|
||
].join('\n')
|
||
expect(await tree(source)).toEqual([
|
||
{ at: 1, kind: 'if', kids: [{ at: 2, kind: 'command' }] },
|
||
{ at: 3, kind: 'else', kids: [{ at: 4, kind: 'command' }] },
|
||
{ at: 5, kind: 'else', kids: [{ at: 6, kind: 'command' }] },
|
||
{ at: 8, kind: 'loop', kids: [{ at: 9, kind: 'command' }] },
|
||
{ at: 11, kind: 'if' },
|
||
{ at: 12, kind: 'if' },
|
||
{ at: 13, kind: 'command' },
|
||
{ at: 14, kind: 'return' },
|
||
])
|
||
})
|
||
})
|
||
|
||
describe('shell 属性表信息(facts)', () => {
|
||
it('解释器:env 按 PATH 找,写死的路径照写;选项:连写的开关拆开,-o 跟长名字,+ 是关掉;set -- 不是选项', async () => {
|
||
expect((await facts(FETCH))[1]).toEqual({ program: 'bash', lookup: 'env', command: '/usr/bin/env bash' })
|
||
expect((await facts('#!/bin/sh -e\n'))[1]).toEqual({ program: 'sh', lookup: 'path', command: '/bin/sh -e' })
|
||
expect((await facts(FETCH))[14]).toEqual({ switches: [
|
||
{ id: 'errexit', on: true, flag: '-e' },
|
||
{ id: 'nounset', on: true, flag: '-u' },
|
||
{ id: 'pipefail', on: true, flag: '-o pipefail' },
|
||
] })
|
||
expect((await facts('set +x -o nounset\n'))[1]).toEqual({ switches: [{ id: 'xtrace', on: false, flag: '+x' }, { id: 'nounset', on: true, flag: '-o nounset' }] })
|
||
expect(await tree('set -- a b\n')).toEqual([{ at: 1, kind: 'command' }])
|
||
})
|
||
|
||
it('变量:local 一句几个是一块,没值的也算;readonly、顶层全大写写死的值是常量,值里有展开的不是', async () => {
|
||
const byLine = await facts(FETCH)
|
||
expect(byLine[48]).toEqual({ declarators: [{ name: 'url', value: '"$1"' }, { name: 'sha', value: '"$2"' }, { name: 'out' }] })
|
||
expect(byLine[21]).toEqual({ declarators: [{ name: 'PY_RELEASE', value: '20260924' }] })
|
||
expect(await tree('readonly n=1\nPY_URL="$x/y"\nx=1\n')).toEqual([{ at: 1, kind: 'const' }, { at: 2, kind: 'variable' }, { at: 3, kind: 'variable' }])
|
||
})
|
||
|
||
it('函数:按位置取的参数(local x="$1" 起的名字)、打印到输出的几句(重定向到错误输出的不算)、输出被 $(…) 拿去用、写了的 return', async () => {
|
||
const byLine = await facts(FETCH)
|
||
expect(byLine[42]).toMatchObject({ name: 'sha_ok', positional: [{ position: 1 }, { position: 2 }], prints: [], captured: false, returns: [] })
|
||
expect(byLine[46]).toMatchObject({
|
||
name: 'fetch',
|
||
doc: '# 下载 (已缓存且校验通过就不重下), 回显本地文件路径',
|
||
positional: [{ position: 1, name: 'url' }, { position: 2, name: 'sha' }],
|
||
prints: ['echo "$out"'],
|
||
captured: true,
|
||
returns: [],
|
||
})
|
||
expect((await facts('check() {\n [ -f "$1" ] || return 1\n return 0\n}\n'))[1]).toMatchObject({ returns: ['return 1', 'return 0'] })
|
||
})
|
||
})
|
||
|
||
describe('shell 被别的语言嵌着当步骤执行(Dockerfile 的 RUN)', () => {
|
||
const steps = async (source: string): Promise<{ blocks: Outline[], chained: boolean, headers: string[] } | undefined> => {
|
||
const parsed = await runtime.parse(adapter, 'shellscript', source)
|
||
try {
|
||
const found = adapter.steps?.(parsed.rootNode, source, 'shellscript')
|
||
return found && { blocks: outline(found.blocks), chained: found.chained, headers: found.blocks.map(block => source.slice(block.header.startIndex, block.header.endIndex)) }
|
||
}
|
||
finally {
|
||
parsed.delete()
|
||
}
|
||
}
|
||
|
||
it('只用 && 连着的一串拆成一条一步(一步失败就停);整串带着的重定向只算在最后一条上,它的头部延到重定向结尾', async () => {
|
||
expect(await steps('apt-get update && echo hi > /f && rm -rf /x')).toEqual({
|
||
blocks: [{ at: 1, kind: 'command' }, { at: 1, kind: 'command' }, { at: 1, kind: 'command' }],
|
||
chained: true,
|
||
headers: ['apt-get update', 'echo hi > /f', 'rm -rf /x'],
|
||
})
|
||
})
|
||
|
||
it('条件串、有 || 的串、几句分开写的照 toBlocks 一句一步,不算一步失败就停', async () => {
|
||
expect(await steps('[ -f x ] && rm x')).toMatchObject({ blocks: [{ at: 1, kind: 'if' }], chained: false })
|
||
expect(await steps('a && b || c')).toMatchObject({ blocks: [{ at: 1, kind: 'command' }], chained: false })
|
||
expect(await steps('a\nb')).toMatchObject({ blocks: [{ at: 1, kind: 'command' }, { at: 2, kind: 'command' }], chained: false })
|
||
})
|
||
})
|
||
|
||
describe('shell 命令、名字', () => {
|
||
const commands = (source: string): Promise<unknown[]> => nodeFacts(adapter, 'shellscript', source, node => adapter.command?.(node))
|
||
|
||
it('命令:各个词连写死的文字(去掉引号、转义),有展开的没有;重定向读成输出 / 错误输出写到、追加到、并进另一路、从文件读', async () => {
|
||
const [cut] = await commands('cut -d\' \' -f1 "$x" a\\ b')
|
||
expect(nodesToText(cut)).toMatchObject({
|
||
name: { node: 'cut', literal: 'cut' },
|
||
arguments: [{ node: '-d\' \'', literal: '-d ' }, { node: '-f1', literal: '-f1' }, { node: '"$x"' }, { node: 'a\\ b', literal: 'a b' }],
|
||
redirects: [],
|
||
})
|
||
// 带重定向的语句和它里面那条命令都认得出,这里只看语句的
|
||
const redirected = await nodeFacts(adapter, 'shellscript', 'echo hi >&2\ncmd 2>/dev/null >> log < in\ncmd &> out 2>&1\n', node => node.type === 'redirected_statement' ? adapter.command?.(node) : undefined)
|
||
expect(redirected.map(facts => (facts as { redirects: unknown }).redirects)).toEqual([
|
||
[{ kind: 'merge', stream: 'output', into: 'error' }],
|
||
[{ kind: 'write', stream: 'error', target: '/dev/null' }, { kind: 'append', stream: 'output', target: 'log' }, { kind: 'read', source: 'in' }],
|
||
[{ kind: 'write', stream: 'both', target: 'out' }, { kind: 'merge', stream: 'error', into: 'output' }],
|
||
])
|
||
})
|
||
|
||
it('同一文件里的名字:函数的定义和调用,变量的赋值、local、for、read 和展开;第几个参数不算', async () => {
|
||
const source = 'fetch() {\n local url="$1" out\n out="$url"\n}\nfor f in a; do read -r line; echo "$f $line"; done\nx="$(fetch "$f")"\n'
|
||
const parsed = await runtime.parse(adapter, 'shellscript', source)
|
||
const symbols = adapter.symbols?.(parsed.rootNode, source, 'shellscript') ?? []
|
||
parsed.delete()
|
||
const at = (text: string, from = 0): number => source.indexOf(text, from)
|
||
const byText = (offset: number): { definitions: string[], occurrences: string[] } | undefined => {
|
||
const symbol = symbols.find(entry => entry.occurrences.some(range => range.startIndex === offset))
|
||
return symbol && {
|
||
definitions: symbol.definitions.map(range => `${source.slice(range.startIndex, range.endIndex)}@${range.startIndex}`),
|
||
occurrences: symbol.occurrences.map(range => `${source.slice(range.startIndex, range.endIndex)}@${range.startIndex}`),
|
||
}
|
||
}
|
||
expect(byText(at('fetch'))).toEqual({ definitions: [`fetch@${at('fetch')}`], occurrences: [`fetch@${at('fetch')}`, `fetch@${at('fetch "$f"')}`] })
|
||
expect(byText(at('out'))).toEqual({
|
||
definitions: [`out@${at('out')}`, `out@${at('out=')}`],
|
||
occurrences: [`out@${at('out')}`, `out@${at('out=')}`],
|
||
})
|
||
expect(byText(at('line'))?.occurrences).toEqual([`line@${at('line')}`, `line@${at('line"')}`])
|
||
expect(symbols.some(entry => entry.occurrences.some(range => source.slice(range.startIndex, range.endIndex) === '1'))).toBe(false)
|
||
})
|
||
|
||
it('中文名:变量赋值、声明行尾的 # @Comment 名字', async () => {
|
||
const source = 'dest="$1" # @Comment 安装目录\nlocal n # @Comment 次数\n'
|
||
const parsed = await runtime.parse(adapter, 'shellscript', source)
|
||
try {
|
||
const found = adapter.displayNames?.find(parsed.rootNode, source, 'shellscript')
|
||
expect(found?.names.map(({ name, text }) => [source.slice(name.startIndex, name.endIndex), text])).toEqual([['dest', '安装目录'], ['n', '次数']])
|
||
}
|
||
finally {
|
||
parsed.delete()
|
||
}
|
||
})
|
||
})
|
||
|
||
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] }])
|
||
})
|
||
})
|